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