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, Eq, Serialize, Deserialize)]
94pub struct AdvertisedAdminCapability {
95 pub name: String,
96 pub status: RuntimeCapabilityStatus,
97}
98
99#[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#[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#[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#[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#[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#[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#[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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
228pub struct DiagnosticCapabilityGuardError {
229 capability: DiagnosticCapability,
230 availability: CapabilityAvailability,
231 reason: Option<String>,
232}
233
234impl DiagnosticCapabilityGuardError {
235 pub const fn capability(&self) -> DiagnosticCapability {
237 self.capability
238 }
239
240 pub const fn availability(&self) -> CapabilityAvailability {
242 self.availability
243 }
244
245 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#[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 pub fn capability(&self, name: &str) -> Option<&CapabilityEntry> {
283 self.capabilities.iter().find(|entry| entry.name == name)
284 }
285
286 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}