Skip to main content

fxcp_core/
stratis.rs

1// SPDX-License-Identifier: GPL-2.0-or-later
2// Copyright (C) 2025 Joel Wirāmu Pauling <aenertia@aenertia.net>
3//! Stratis D-Bus integration for pool-aware replication.
4//!
5//! Queries stratisd via the system D-Bus to resolve pool/filesystem metadata,
6//! monitor pool capacity, create/prune filesystem snapshots, and provide
7//! rich storage telemetry for the Governor and metrics subsystem.
8//!
9//! stratisd D-Bus API: `org.storage.stratis3` on the system bus.
10//! Reference: <https://stratis-storage.github.io/DBusAPIReference.pdf>
11
12#![allow(clippy::expect_used)]
13use std::collections::HashMap;
14use tracing::{debug, info, warn};
15
16/// Stratis-related errors.
17#[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/// Rich Stratis pool metadata resolved from D-Bus.
36#[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/// A Stratis filesystem snapshot entry.
57#[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/// Pool capacity summary.
68#[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
76/// Check if stratisd is available on the system D-Bus.
77pub 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
94/// Resolve a mount path or devnode to its Stratis pool and filesystem via D-Bus.
95///
96/// Accepts either a devnode path (e.g., `/dev/stratis/pool/fs`) or a mount path
97/// (e.g., `/mnt/target-stratis`). For mount paths, resolves the backing device
98/// via `/proc/self/mountinfo` first.
99///
100/// Returns error if stratisd is not running or the path is not on Stratis.
101pub async fn resolve_stratis_target(
102    conn: &zbus::Connection,
103    path_or_devnode: &str,
104) -> Result<StratisPoolInfo, StratisError> {
105    // If the input is a mount path (not a /dev/ path), resolve to devnode
106    let resolved_devnode = if path_or_devnode.starts_with("/dev/") {
107        path_or_devnode.to_string()
108    } else {
109        // Try to resolve mount path -> backing device via /proc/self/mountinfo
110        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    // First pass: find the filesystem matching our devnode (try both resolved and original)
122    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                // Match against resolved devnode, original path, or symlink target
133                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    // Extract filesystem properties
156    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    // Get the pool object path from the filesystem's Pool property
163    let pool_obj_path = extract_object_path(&props, "Pool")
164        .ok_or_else(|| StratisError::Parse("Missing Pool property".to_string()))?;
165
166    // Get origin (if this filesystem is a snapshot)
167    let origin = extract_optional_string(&props, "Origin");
168
169    // Second pass: find the pool and extract its properties
170    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
232/// Query pool physical capacity.
233pub 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    // Use the latest pool interface revision available
244    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    // TotalPhysicalUsed is (bool, string) on D-Bus
255    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
275/// Create a Stratis filesystem snapshot via D-Bus.
276pub 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    // Call SnapshotFilesystem on the pool object
283    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    // SnapshotFilesystem(filesystem_object_path: o, snapshot_name: s)
291    // Returns: ((b, o), (q, s))  --  (success+path, return_code+message)
292    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
308/// Enumerate all Stratis filesystem snapshots in a pool.
309pub 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            // Check if this filesystem belongs to our pool
333            let fs_pool = extract_object_path(&pmap, "Pool").unwrap_or_default();
334            if fs_pool != pool_object_path {
335                continue;
336            }
337
338            // Check if this is a snapshot (has Origin property set)
339            let origin = extract_optional_string(&pmap, "Origin");
340            if origin.is_none() {
341                continue; // Not a snapshot
342            }
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    // Sort by creation time
356    snapshots.sort_by(|a, b| a.created.cmp(&b.created));
357
358    Ok(snapshots)
359}
360
361/// Prune old foxing-created Stratis snapshots, keeping the N most recent.
362/// Only prunes snapshots whose name starts with `prefix`.
363/// Tagged snapshots (name contains user-defined tags) are never pruned.
364pub 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    // Filter to foxing-managed snapshots
373    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    // Delete oldest (list is sorted by creation time)
384    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    // DestroyFilesystems([object_path, ...])
401    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
415// --- Helper functions for D-Bus property extraction (zvariant v5 API) ---
416// zvariant v5: downcast_ref<T>() returns Result<T, Error> where T: TryFrom<&Value>
417
418fn extract_string(props: &HashMap<String, zvariant::OwnedValue>, key: &str) -> Option<String> {
419    props.get(key).and_then(|v| {
420        // downcast_ref::<&str>() returns Result<&str, Error>
421        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    // D-Bus type (bs)  --  (bool, string). Stratis uses this for optional values.
427    // In zvariant v5, access via Structure fields.
428    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
472/// Resolve a symlink to its target (for comparing /dev/stratis/pool/fs with /dev/dm-N).
473fn 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        // This test works regardless of whether stratisd is running
500        let available = is_stratisd_available().await;
501        // Just verify it doesn't panic
502        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}