Skip to main content

rc_core/admin/
capabilities.rs

1//! Runtime capability discovery contracts for the RustFS Admin API.
2
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5use std::fmt;
6
7/// Effective availability of an Admin API capability.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "kebab-case")]
10pub enum CapabilityAvailability {
11    Available,
12    Stubbed,
13    Unsupported,
14    Disabled,
15    VersionGated,
16    PermissionDenied,
17    Unknown,
18}
19
20impl fmt::Display for CapabilityAvailability {
21    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
22        let value = match self {
23            Self::Available => "available",
24            Self::Stubbed => "stubbed",
25            Self::Unsupported => "unsupported",
26            Self::Disabled => "disabled",
27            Self::VersionGated => "version-gated",
28            Self::PermissionDenied => "permission-denied",
29            Self::Unknown => "unknown",
30        };
31        formatter.write_str(value)
32    }
33}
34
35/// RustFS capability state returned by runtime snapshot endpoints.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum RuntimeCapabilityState {
39    Supported,
40    Unsupported,
41    Disabled,
42    #[serde(other)]
43    Unknown,
44}
45
46/// A typed status from a RustFS runtime snapshot.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct RuntimeCapabilityStatus {
49    pub state: RuntimeCapabilityState,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub reason: Option<String>,
52    #[serde(flatten, default)]
53    pub extra: BTreeMap<String, serde_json::Value>,
54}
55
56impl RuntimeCapabilityStatus {
57    /// Convert the server contract into an effective client availability.
58    pub const fn availability(&self) -> CapabilityAvailability {
59        match self.state {
60            RuntimeCapabilityState::Supported => CapabilityAvailability::Available,
61            RuntimeCapabilityState::Unsupported => CapabilityAvailability::Unsupported,
62            RuntimeCapabilityState::Disabled => CapabilityAvailability::Disabled,
63            RuntimeCapabilityState::Unknown => CapabilityAvailability::Unknown,
64        }
65    }
66
67    /// Stable display label for the server-reported state.
68    pub const fn state_label(&self) -> &'static str {
69        match self.state {
70            RuntimeCapabilityState::Supported => "supported",
71            RuntimeCapabilityState::Unsupported => "unsupported",
72            RuntimeCapabilityState::Disabled => "disabled",
73            RuntimeCapabilityState::Unknown => "unknown",
74        }
75    }
76}
77
78/// Summary fields provided by `/v4/runtime/capabilities`.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct RuntimeCapabilitiesSummary {
81    pub observability: RuntimeCapabilityStatus,
82    pub userspace_profiling: RuntimeCapabilityStatus,
83    pub memory_sampling: RuntimeCapabilityStatus,
84    pub platform: RuntimeCapabilityStatus,
85    pub topology: RuntimeCapabilityStatus,
86    pub cluster_snapshot: RuntimeCapabilityStatus,
87}
88
89/// One named admin capability advertised by the server itself
90/// (rustfs/backlog#1900). Servers after 1.0.0-rc.3 include this list in
91/// `/v4/runtime/capabilities`; older servers omit it, in which case the
92/// client falls back to its pinned per-version contract.
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub struct AdvertisedAdminCapability {
95    pub name: String,
96    pub status: RuntimeCapabilityStatus,
97}
98
99/// Typed subset of the RustFS runtime capability response.
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
101pub struct RuntimeCapabilitiesSnapshot {
102    pub summary: RuntimeCapabilitiesSummary,
103    #[serde(default)]
104    pub advertised: Vec<AdvertisedAdminCapability>,
105    #[serde(default)]
106    pub inspect_archive: Option<super::InspectArchiveCapabilityContract>,
107    #[serde(default)]
108    pub site_replication_repair: Option<super::SiteReplicationRepairCapabilityContract>,
109    pub cluster_snapshot_path: String,
110    pub cluster_snapshot_summary: Option<RuntimeCapabilityStatus>,
111    pub topology_status: RuntimeCapabilityStatus,
112    #[serde(default)]
113    pub observability: serde_json::Value,
114    #[serde(default)]
115    pub workload_admission: serde_json::Value,
116    #[serde(default)]
117    pub topology: Option<serde_json::Value>,
118}
119
120/// Extension metadata advertised by RustFS.
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct ExtensionMetadata {
123    pub schema_version: String,
124    pub extension_id: String,
125    pub display_name: String,
126    pub provider: String,
127    pub version: String,
128    pub kind: String,
129    #[serde(default)]
130    pub runtime: serde_json::Value,
131    #[serde(default)]
132    pub capabilities: Vec<String>,
133    pub disabled_by_default: bool,
134    #[serde(flatten, default)]
135    pub extra: BTreeMap<String, serde_json::Value>,
136}
137
138/// Typed subset of `/v4/extensions/catalog`.
139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
140pub struct ExtensionsCatalog {
141    pub extensions: Vec<ExtensionMetadata>,
142    pub runtime_capabilities: BTreeMap<String, serde_json::Value>,
143    pub cluster_snapshot: BTreeMap<String, serde_json::Value>,
144    pub external_plugin_flow: BTreeMap<String, serde_json::Value>,
145    #[serde(flatten, default)]
146    pub extra: BTreeMap<String, serde_json::Value>,
147}
148
149/// Typed summary returned inside a cluster snapshot.
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151pub struct ClusterSnapshotSummary {
152    pub runtime: RuntimeCapabilityStatus,
153    pub topology: RuntimeCapabilityStatus,
154    pub membership: RuntimeCapabilityStatus,
155    pub peer_health: RuntimeCapabilityStatus,
156    pub rpc_boundary: RuntimeCapabilityStatus,
157    pub observability: RuntimeCapabilityStatus,
158    pub workload_admission: RuntimeCapabilityStatus,
159    pub actionable_pressure: RuntimeCapabilityStatus,
160}
161
162/// Metadata from `/v4/cluster/snapshot` without exposing server internals.
163#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
164pub struct ClusterSnapshotMetadata {
165    pub summary: Option<ClusterSnapshotSummary>,
166    pub runtime_capabilities_path: Option<String>,
167    pub extensions_catalog_path: Option<String>,
168}
169
170/// One normalized capability row shown by the CLI.
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172pub struct CapabilityEntry {
173    pub name: String,
174    pub availability: CapabilityAvailability,
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub reason: Option<String>,
177}
178
179/// A diagnostic operation whose support must be known before it is invoked.
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub enum DiagnosticCapability {
182    HealthSnapshot,
183    ClusterSnapshot,
184    ExtensionsCatalog,
185    DriveObservations,
186    ClientDevnull,
187    InspectArchive,
188    ObjectSpeedtest,
189    NetworkSpeedtest,
190    SiteSpeedtest,
191    SiteReplicationNetperf,
192}
193
194impl DiagnosticCapability {
195    /// All diagnostic capabilities in stable display order.
196    pub const ALL: [Self; 10] = [
197        Self::HealthSnapshot,
198        Self::ClusterSnapshot,
199        Self::ExtensionsCatalog,
200        Self::DriveObservations,
201        Self::ClientDevnull,
202        Self::InspectArchive,
203        Self::ObjectSpeedtest,
204        Self::NetworkSpeedtest,
205        Self::SiteSpeedtest,
206        Self::SiteReplicationNetperf,
207    ];
208
209    /// Stable machine-readable capability name.
210    pub const fn name(self) -> &'static str {
211        match self {
212            Self::HealthSnapshot => "admin.diagnostics.health-snapshot",
213            Self::ClusterSnapshot => "admin.diagnostics.cluster-snapshot",
214            Self::ExtensionsCatalog => "admin.diagnostics.extensions-catalog",
215            Self::DriveObservations => "admin.diagnostics.drive-observations",
216            Self::ClientDevnull => "admin.diagnostics.client-devnull",
217            Self::InspectArchive => "admin.diagnostics.inspect-archive",
218            Self::ObjectSpeedtest => "admin.diagnostics.object-speedtest",
219            Self::NetworkSpeedtest => "admin.diagnostics.network-speedtest",
220            Self::SiteSpeedtest => "admin.diagnostics.site-speedtest",
221            Self::SiteReplicationNetperf => "admin.site-replication.netperf",
222        }
223    }
224}
225
226/// Error returned when a diagnostic operation is not explicitly available.
227#[derive(Debug, Clone, PartialEq, Eq)]
228pub struct DiagnosticCapabilityGuardError {
229    capability: DiagnosticCapability,
230    availability: CapabilityAvailability,
231    reason: Option<String>,
232}
233
234impl DiagnosticCapabilityGuardError {
235    /// Diagnostic operation rejected by the guard.
236    pub const fn capability(&self) -> DiagnosticCapability {
237        self.capability
238    }
239
240    /// Effective support classification that caused the rejection.
241    pub const fn availability(&self) -> CapabilityAvailability {
242        self.availability
243    }
244
245    /// Server or client explanation for the classification, when available.
246    pub fn reason(&self) -> Option<&str> {
247        self.reason.as_deref()
248    }
249}
250
251impl fmt::Display for DiagnosticCapabilityGuardError {
252    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
253        write!(
254            formatter,
255            "diagnostic capability '{}' is {}",
256            self.capability.name(),
257            self.availability
258        )?;
259        if let Some(reason) = &self.reason {
260            write!(formatter, ": {reason}")?;
261        }
262        Ok(())
263    }
264}
265
266impl std::error::Error for DiagnosticCapabilityGuardError {}
267
268/// Aggregate capability discovery result.
269#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
270pub struct CapabilityReport {
271    pub server_version: Option<String>,
272    pub runtime_path: String,
273    pub extensions_path: String,
274    pub cluster_snapshot_path: String,
275    pub capabilities: Vec<CapabilityEntry>,
276    pub extensions: Vec<ExtensionMetadata>,
277    pub cluster: ClusterSnapshotMetadata,
278}
279
280impl CapabilityReport {
281    /// Find a normalized capability by its stable machine-readable name.
282    pub fn capability(&self, name: &str) -> Option<&CapabilityEntry> {
283        self.capabilities.iter().find(|entry| entry.name == name)
284    }
285
286    /// Require explicit availability before invoking a diagnostic operation.
287    ///
288    /// Missing entries and every non-available state fail closed. This prevents
289    /// route presence or placeholder HTTP success responses from being treated
290    /// as proof that an active diagnostic is implemented.
291    pub fn require_diagnostic_capability(
292        &self,
293        capability: DiagnosticCapability,
294    ) -> Result<&CapabilityEntry, DiagnosticCapabilityGuardError> {
295        match self.capability(capability.name()) {
296            Some(entry) if entry.availability == CapabilityAvailability::Available => Ok(entry),
297            Some(entry) => Err(DiagnosticCapabilityGuardError {
298                capability,
299                availability: entry.availability,
300                reason: entry.reason.clone(),
301            }),
302            None => Err(DiagnosticCapabilityGuardError {
303                capability,
304                availability: CapabilityAvailability::Unknown,
305                reason: None,
306            }),
307        }
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    fn report(capabilities: Vec<CapabilityEntry>) -> CapabilityReport {
316        CapabilityReport {
317            server_version: Some("1.0.0-beta.10".to_string()),
318            runtime_path: "/v4/runtime/capabilities".to_string(),
319            extensions_path: "/v4/extensions/catalog".to_string(),
320            cluster_snapshot_path: "/v4/cluster/snapshot".to_string(),
321            capabilities,
322            extensions: Vec::new(),
323            cluster: ClusterSnapshotMetadata {
324                summary: None,
325                runtime_capabilities_path: None,
326                extensions_catalog_path: None,
327            },
328        }
329    }
330
331    #[test]
332    fn runtime_states_map_without_overstating_support() {
333        let status = |state| RuntimeCapabilityStatus {
334            state,
335            reason: None,
336            extra: BTreeMap::new(),
337        };
338
339        assert_eq!(
340            status(RuntimeCapabilityState::Supported).availability(),
341            CapabilityAvailability::Available
342        );
343        assert_eq!(
344            status(RuntimeCapabilityState::Unsupported).availability(),
345            CapabilityAvailability::Unsupported
346        );
347        assert_eq!(
348            status(RuntimeCapabilityState::Disabled).availability(),
349            CapabilityAvailability::Disabled
350        );
351        assert_eq!(
352            status(RuntimeCapabilityState::Unknown).availability(),
353            CapabilityAvailability::Unknown
354        );
355    }
356
357    #[test]
358    fn availability_has_stable_machine_readable_values() {
359        assert_eq!(
360            serde_json::to_string(&CapabilityAvailability::VersionGated)
361                .expect("availability should serialize"),
362            "\"version-gated\""
363        );
364        assert_eq!(
365            CapabilityAvailability::PermissionDenied.to_string(),
366            "permission-denied"
367        );
368        assert_eq!(
369            serde_json::to_string(&CapabilityAvailability::Unsupported)
370                .expect("availability should serialize"),
371            "\"unsupported\""
372        );
373    }
374
375    #[test]
376    fn diagnostic_capability_names_are_stable_and_unique() {
377        let names = DiagnosticCapability::ALL.map(DiagnosticCapability::name);
378
379        assert_eq!(names.len(), 10);
380        assert_eq!(names[0], "admin.diagnostics.health-snapshot");
381        assert_eq!(names[9], "admin.site-replication.netperf");
382        for (index, name) in names.iter().enumerate() {
383            assert!(!names[..index].contains(name), "duplicate name: {name}");
384        }
385    }
386
387    #[test]
388    fn diagnostic_guard_allows_only_available_capabilities() {
389        let capability = DiagnosticCapability::HealthSnapshot;
390        let report = report(vec![CapabilityEntry {
391            name: capability.name().to_string(),
392            availability: CapabilityAvailability::Available,
393            reason: Some("Pinned server contract".to_string()),
394        }]);
395
396        let entry = report
397            .require_diagnostic_capability(capability)
398            .expect("available diagnostics should pass the guard");
399
400        assert_eq!(entry.name, capability.name());
401    }
402
403    #[test]
404    fn diagnostic_guard_preserves_non_available_states() {
405        let capability = DiagnosticCapability::ObjectSpeedtest;
406        let blocked_states = [
407            CapabilityAvailability::Stubbed,
408            CapabilityAvailability::Unsupported,
409            CapabilityAvailability::Disabled,
410            CapabilityAvailability::VersionGated,
411            CapabilityAvailability::PermissionDenied,
412            CapabilityAvailability::Unknown,
413        ];
414
415        for availability in blocked_states {
416            let report = report(vec![CapabilityEntry {
417                name: capability.name().to_string(),
418                availability,
419                reason: Some("Server classification".to_string()),
420            }]);
421            let error = report
422                .require_diagnostic_capability(capability)
423                .expect_err("non-available diagnostics must fail closed");
424
425            assert_eq!(error.capability(), capability);
426            assert_eq!(error.availability(), availability);
427            assert_eq!(error.reason(), Some("Server classification"));
428        }
429    }
430
431    #[test]
432    fn diagnostic_guard_treats_missing_entries_as_unknown() {
433        let capability = DiagnosticCapability::NetworkSpeedtest;
434        let error = report(Vec::new())
435            .require_diagnostic_capability(capability)
436            .expect_err("missing diagnostic classifications must fail closed");
437
438        assert_eq!(error.capability(), capability);
439        assert_eq!(error.availability(), CapabilityAvailability::Unknown);
440        assert_eq!(error.reason(), None);
441    }
442}