1#![allow(clippy::expect_used)]
13use std::collections::HashMap;
14use tracing::{debug, info, warn};
15
16#[derive(Debug, thiserror::Error)]
18pub enum StratisError {
19 #[error("D-Bus error: {0}")]
20 DBus(#[from] zbus::Error),
21 #[error("D-Bus fdo error: {0}")]
22 Fdo(#[from] zbus::fdo::Error),
23 #[error("stratisd not available on D-Bus")]
24 NotAvailable,
25 #[error("pool not found: {0}")]
26 PoolNotFound(String),
27 #[error("filesystem not found: {0}")]
28 FilesystemNotFound(String),
29 #[error("API error from stratisd: {0}")]
30 Api(String),
31 #[error("parse error: {0}")]
32 Parse(String),
33}
34
35#[derive(Debug, Clone)]
37pub struct StratisPoolInfo {
38 pub pool_name: String,
39 pub pool_uuid: String,
40 pub pool_object_path: String,
41 pub filesystem_name: String,
42 pub filesystem_uuid: String,
43 pub filesystem_object_path: String,
44 pub total_physical_size: u64,
45 pub total_physical_used: Option<u64>,
46 pub no_alloc_space: bool,
47 pub encrypted: bool,
48 pub has_cache: bool,
49 pub overprovisioning: bool,
50 pub fs_size: u64,
51 pub fs_used: Option<u64>,
52 pub fs_devnode: String,
53 pub origin: Option<String>,
54}
55
56#[derive(Debug, Clone)]
58pub struct StratisSnapshot {
59 pub name: String,
60 pub object_path: String,
61 pub created: String,
62 pub origin_path: Option<String>,
63 pub size: u64,
64 pub used: Option<u64>,
65}
66
67#[derive(Debug, Clone)]
69pub struct PoolCapacity {
70 pub total_bytes: u64,
71 pub used_bytes: u64,
72 pub free_pct: f64,
73 pub no_alloc_space: bool,
74}
75
76pub async fn is_stratisd_available() -> bool {
78 match zbus::Connection::system().await {
79 Ok(conn) => {
80 let proxy = zbus::fdo::DBusProxy::new(&conn).await;
81 match proxy {
82 Ok(p) => {
83 #[allow(clippy::unwrap_used)]
84 let bus_name: zbus::names::BusName = "org.storage.stratis3".try_into().unwrap();
85 p.name_has_owner(bus_name).await.unwrap_or(false)
86 }
87 Err(_) => false,
88 }
89 }
90 Err(_) => false,
91 }
92}
93
94pub async fn resolve_stratis_target(
102 conn: &zbus::Connection,
103 path_or_devnode: &str,
104) -> Result<StratisPoolInfo, StratisError> {
105 let resolved_devnode = if path_or_devnode.starts_with("/dev/") {
107 path_or_devnode.to_string()
108 } else {
109 resolve_mount_to_devnode(path_or_devnode).unwrap_or_else(|| path_or_devnode.to_string())
111 };
112
113 let proxy = zbus::fdo::ObjectManagerProxy::builder(conn)
114 .destination("org.storage.stratis3")?
115 .path("/org/storage/stratis3")?
116 .build()
117 .await?;
118
119 let objects = proxy.get_managed_objects().await?;
120
121 let mut fs_path = None;
123 let mut fs_props: Option<HashMap<String, zvariant::OwnedValue>> = None;
124
125 for (path, interfaces) in &objects {
126 for (iface_name, props) in interfaces {
127 if !iface_name.as_str().starts_with("org.storage.stratis3.filesystem") {
128 continue;
129 }
130 if let Some(dn) = props.get("Devnode") {
131 let dn_str: &str = dn.downcast_ref::<&str>().unwrap_or("");
132 if dn_str == resolved_devnode
134 || dn_str == path_or_devnode
135 || resolve_symlink(dn_str) == resolve_symlink(&resolved_devnode)
136 {
137 fs_path = Some(path.to_string());
138 fs_props = Some(props.iter().map(|(k, v)| {
139 (k.to_string(), v.clone())
140 }).collect());
141 break;
142 }
143 }
144 }
145 if fs_path.is_some() { break; }
146 }
147
148 let fs_obj_path = fs_path.ok_or_else(|| {
149 StratisError::FilesystemNotFound(path_or_devnode.to_string())
150 })?;
151 let props = fs_props.ok_or_else(|| {
152 StratisError::FilesystemNotFound(path_or_devnode.to_string())
153 })?;
154
155 let fs_name = extract_string(&props, "Name").unwrap_or_default();
157 let fs_uuid = extract_string(&props, "Uuid").unwrap_or_default();
158 let fs_size = extract_size_string(&props, "Size").unwrap_or(0);
159 let fs_used = extract_optional_size(&props, "Used");
160 let fs_devnode_val = extract_string(&props, "Devnode").unwrap_or_default();
161
162 let pool_obj_path = extract_object_path(&props, "Pool")
164 .ok_or_else(|| StratisError::Parse("Missing Pool property".to_string()))?;
165
166 let origin = extract_optional_string(&props, "Origin");
168
169 let mut pool_name = String::new();
171 let mut pool_uuid = String::new();
172 let mut total_physical_size: u64 = 0;
173 let mut total_physical_used: Option<u64> = None;
174 let mut no_alloc_space = false;
175 let mut encrypted = false;
176 let mut has_cache = false;
177 let mut overprovisioning = false;
178
179 for (path, interfaces) in &objects {
180 if path.as_str() != pool_obj_path {
181 continue;
182 }
183 for (iface_name, props) in interfaces {
184 if !iface_name.as_str().starts_with("org.storage.stratis3.pool") {
185 continue;
186 }
187 let pmap: HashMap<String, zvariant::OwnedValue> = props.iter()
188 .map(|(k, v)| (k.to_string(), v.clone()))
189 .collect();
190
191 pool_name = extract_string(&pmap, "Name").unwrap_or_default();
192 pool_uuid = extract_string(&pmap, "Uuid").unwrap_or_default();
193 total_physical_size = extract_size_string(&pmap, "TotalPhysicalSize").unwrap_or(0);
194 total_physical_used = extract_optional_size(&pmap, "TotalPhysicalUsed");
195 no_alloc_space = extract_bool(&pmap, "NoAllocSpace").unwrap_or(false);
196 encrypted = extract_bool(&pmap, "Encrypted").unwrap_or(false);
197 has_cache = extract_bool(&pmap, "HasCache").unwrap_or(false);
198 overprovisioning = extract_bool(&pmap, "Overprovisioning").unwrap_or(false);
199 break;
200 }
201 }
202
203 if pool_name.is_empty() {
204 return Err(StratisError::PoolNotFound(pool_obj_path));
205 }
206
207 info!(
208 "Resolved Stratis target: pool={} fs={} encrypted={} cache={} physical_used={:?}",
209 pool_name, fs_name, encrypted, has_cache, total_physical_used
210 );
211
212 Ok(StratisPoolInfo {
213 pool_name,
214 pool_uuid,
215 pool_object_path: pool_obj_path,
216 filesystem_name: fs_name,
217 filesystem_uuid: fs_uuid,
218 filesystem_object_path: fs_obj_path,
219 total_physical_size,
220 total_physical_used,
221 no_alloc_space,
222 encrypted,
223 has_cache,
224 overprovisioning,
225 fs_size,
226 fs_used,
227 fs_devnode: fs_devnode_val,
228 origin,
229 })
230}
231
232pub async fn query_pool_capacity(
234 conn: &zbus::Connection,
235 pool_object_path: &str,
236) -> Result<PoolCapacity, StratisError> {
237 let proxy = zbus::fdo::PropertiesProxy::builder(conn)
238 .destination("org.storage.stratis3")?
239 .path(pool_object_path)?
240 .build()
241 .await?;
242
243 let iface: zbus::names::InterfaceName = "org.storage.stratis3.pool.r8"
245 .try_into()
246 .map_err(|_| StratisError::Parse("Invalid interface name".to_string()))?;
247
248 let total_val = proxy.get(iface.clone(), "TotalPhysicalSize").await?;
249 let total: u64 = total_val.downcast_ref::<&str>()
250 .unwrap_or("0")
251 .parse()
252 .map_err(|e| StratisError::Parse(format!("TotalPhysicalSize: {}", e)))?;
253
254 let used_val = proxy.get(iface.clone(), "TotalPhysicalUsed").await?;
256 let used = parse_optional_size_tuple(&used_val).unwrap_or(0);
257
258 let no_alloc_val = proxy.get(iface, "NoAllocSpace").await?;
259 let no_alloc = no_alloc_val.downcast_ref::<bool>().unwrap_or(false);
260
261 let free_pct = if total > 0 {
262 ((total.saturating_sub(used)) as f64 / total as f64) * 100.0
263 } else {
264 0.0
265 };
266
267 Ok(PoolCapacity {
268 total_bytes: total,
269 used_bytes: used,
270 free_pct,
271 no_alloc_space: no_alloc,
272 })
273}
274
275pub async fn create_stratis_snapshot(
277 conn: &zbus::Connection,
278 pool_object_path: &str,
279 filesystem_object_path: &str,
280 snapshot_name: &str,
281) -> Result<String, StratisError> {
282 let proxy = zbus::Proxy::new(
284 conn,
285 "org.storage.stratis3",
286 pool_object_path,
287 "org.storage.stratis3.pool.r8",
288 ).await?;
289
290 let reply: ((bool, zvariant::OwnedObjectPath), (u16, String)) = proxy
293 .call("SnapshotFilesystem", &(filesystem_object_path, snapshot_name))
294 .await?;
295
296 let ((success, snap_path), (return_code, message)) = reply;
297
298 if return_code != 0 || !success {
299 return Err(StratisError::Api(format!(
300 "SnapshotFilesystem failed (code {}): {}", return_code, message
301 )));
302 }
303
304 info!("Stratis snapshot created: {} -> {}", snapshot_name, snap_path.as_str());
305 Ok(snap_path.to_string())
306}
307
308pub async fn enumerate_stratis_snapshots(
310 conn: &zbus::Connection,
311 pool_object_path: &str,
312) -> Result<Vec<StratisSnapshot>, StratisError> {
313 let proxy = zbus::fdo::ObjectManagerProxy::builder(conn)
314 .destination("org.storage.stratis3")?
315 .path("/org/storage/stratis3")?
316 .build()
317 .await?;
318
319 let objects = proxy.get_managed_objects().await?;
320 let mut snapshots = Vec::new();
321
322 for (path, interfaces) in &objects {
323 for (iface_name, props) in interfaces {
324 if !iface_name.as_str().starts_with("org.storage.stratis3.filesystem") {
325 continue;
326 }
327
328 let pmap: HashMap<String, zvariant::OwnedValue> = props.iter()
329 .map(|(k, v)| (k.to_string(), v.clone()))
330 .collect();
331
332 let fs_pool = extract_object_path(&pmap, "Pool").unwrap_or_default();
334 if fs_pool != pool_object_path {
335 continue;
336 }
337
338 let origin = extract_optional_string(&pmap, "Origin");
340 if origin.is_none() {
341 continue; }
343
344 snapshots.push(StratisSnapshot {
345 name: extract_string(&pmap, "Name").unwrap_or_default(),
346 object_path: path.to_string(),
347 created: extract_string(&pmap, "Created").unwrap_or_default(),
348 origin_path: origin,
349 size: extract_size_string(&pmap, "Size").unwrap_or(0),
350 used: extract_optional_size(&pmap, "Used"),
351 });
352 }
353 }
354
355 snapshots.sort_by(|a, b| a.created.cmp(&b.created));
357
358 Ok(snapshots)
359}
360
361pub async fn prune_stratis_snapshots(
365 conn: &zbus::Connection,
366 pool_object_path: &str,
367 prefix: &str,
368 keep: usize,
369) -> Result<usize, StratisError> {
370 let snapshots = enumerate_stratis_snapshots(conn, pool_object_path).await?;
371
372 let foxing_snaps: Vec<&StratisSnapshot> = snapshots.iter()
374 .filter(|s| s.name.starts_with(prefix))
375 .collect();
376
377 let to_delete = if foxing_snaps.len() > keep {
378 foxing_snaps.len() - keep
379 } else {
380 return Ok(0);
381 };
382
383 let delete_paths: Vec<&str> = foxing_snaps[..to_delete]
385 .iter()
386 .map(|s| s.object_path.as_str())
387 .collect();
388
389 if delete_paths.is_empty() {
390 return Ok(0);
391 }
392
393 let proxy = zbus::Proxy::new(
394 conn,
395 "org.storage.stratis3",
396 pool_object_path,
397 "org.storage.stratis3.pool.r8",
398 ).await?;
399
400 let reply: ((bool, Vec<zvariant::OwnedObjectPath>), (u16, String)) = proxy
402 .call("DestroyFilesystems", &(&delete_paths,))
403 .await?;
404
405 let ((_, _destroyed), (return_code, message)) = reply;
406
407 if return_code != 0 {
408 warn!("Stratis snapshot pruning warning (code {}): {}", return_code, message);
409 }
410
411 info!("Pruned {} Stratis snapshots (kept {})", to_delete, keep);
412 Ok(to_delete)
413}
414
415fn extract_string(props: &HashMap<String, zvariant::OwnedValue>, key: &str) -> Option<String> {
419 props.get(key).and_then(|v| {
420 v.downcast_ref::<&str>().ok().map(|s| s.to_string())
422 })
423}
424
425fn extract_optional_string(props: &HashMap<String, zvariant::OwnedValue>, key: &str) -> Option<String> {
426 props.get(key).and_then(|v| {
429 if let Ok(structure) = v.downcast_ref::<zvariant::Structure<'_>>() {
430 let fields = structure.fields();
431 if fields.len() == 2 {
432 let valid = fields[0].downcast_ref::<bool>().unwrap_or(false);
433 if valid {
434 return fields[1].downcast_ref::<&str>().ok().map(|s| s.to_string());
435 }
436 }
437 }
438 None
439 })
440}
441
442fn extract_bool(props: &HashMap<String, zvariant::OwnedValue>, key: &str) -> Option<bool> {
443 props.get(key).and_then(|v| v.downcast_ref::<bool>().ok())
444}
445
446fn extract_object_path(props: &HashMap<String, zvariant::OwnedValue>, key: &str) -> Option<String> {
447 props.get(key).and_then(|v| {
448 v.downcast_ref::<zvariant::ObjectPath<'_>>()
449 .ok()
450 .map(|p| p.to_string())
451 })
452}
453
454fn extract_size_string(props: &HashMap<String, zvariant::OwnedValue>, key: &str) -> Option<u64> {
455 extract_string(props, key).and_then(|s| s.parse().ok())
456}
457
458fn extract_optional_size(props: &HashMap<String, zvariant::OwnedValue>, key: &str) -> Option<u64> {
459 extract_optional_string(props, key).and_then(|s| s.parse().ok())
460}
461
462fn resolve_mount_to_devnode(mount_path: &str) -> Option<String> {
463 let path = std::path::Path::new(mount_path);
464 let entry = crate::mount_info::find_mount_for_path(path)?;
465 if entry.source.is_empty() || entry.source == "none" {
466 return None;
467 }
468 debug!("resolve_mount_to_devnode: {} -> {}", mount_path, entry.source);
469 Some(entry.source)
470}
471
472fn resolve_symlink(path: &str) -> String {
474 std::fs::canonicalize(path)
475 .map(|p| p.to_string_lossy().into_owned())
476 .unwrap_or_else(|_| path.to_string())
477}
478
479fn parse_optional_size_tuple(val: &zvariant::OwnedValue) -> Option<u64> {
480 if let Ok(structure) = val.downcast_ref::<zvariant::Structure<'_>>() {
481 let fields = structure.fields();
482 if fields.len() == 2 {
483 let valid = fields[0].downcast_ref::<bool>().unwrap_or(false);
484 if valid {
485 return fields[1].downcast_ref::<&str>().ok().and_then(|s| s.parse().ok());
486 }
487 }
488 }
489 None
490}
491
492#[cfg(test)]
493mod tests {
494 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
495 use super::*;
496
497 #[tokio::test]
498 async fn test_stratisd_availability_check() {
499 let available = is_stratisd_available().await;
501 println!("stratisd available: {}", available);
503 }
504
505 #[test]
506 fn test_snapshot_name_generation() {
507 let name = format!(
508 "foxing-snap-{}",
509 chrono::Utc::now().format("%Y%m%dT%H%M%S")
510 );
511 assert!(name.starts_with("foxing-snap-"));
512 assert!(name.len() > 20);
513 }
514
515 #[test]
516 fn test_pool_capacity_free_pct() {
517 let cap = PoolCapacity {
518 total_bytes: 1_000_000_000,
519 used_bytes: 700_000_000,
520 free_pct: 30.0,
521 no_alloc_space: false,
522 };
523 assert!((cap.free_pct - 30.0).abs() < 0.01);
524 }
525}