1use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5use std::fmt;
6
7#[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#[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#[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 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 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#[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#[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#[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#[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#[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#[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#[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#[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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
216pub struct DiagnosticCapabilityGuardError {
217 capability: DiagnosticCapability,
218 availability: CapabilityAvailability,
219 reason: Option<String>,
220}
221
222impl DiagnosticCapabilityGuardError {
223 pub const fn capability(&self) -> DiagnosticCapability {
225 self.capability
226 }
227
228 pub const fn availability(&self) -> CapabilityAvailability {
230 self.availability
231 }
232
233 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#[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 pub fn capability(&self, name: &str) -> Option<&CapabilityEntry> {
271 self.capabilities.iter().find(|entry| entry.name == name)
272 }
273
274 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}