ryra_core/data/
volumes.rs1use std::path::{Path, PathBuf};
9
10use crate::error::{Error, Result};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct VolumeRef {
14 pub name: String,
17 pub quadlet_file: Option<PathBuf>,
21 pub owner: Option<String>,
24}
25
26pub fn parse_volume_quadlets(
33 quadlet_dir: &Path,
34 known_services: &[String],
35) -> Result<Vec<VolumeRef>> {
36 let mut out = Vec::new();
37 if !quadlet_dir.is_dir() {
38 return Ok(out);
39 }
40 for entry in std::fs::read_dir(quadlet_dir).map_err(|source| Error::FileRead {
41 path: quadlet_dir.to_path_buf(),
42 source,
43 })? {
44 let entry = entry.map_err(|source| Error::FileRead {
45 path: quadlet_dir.to_path_buf(),
46 source,
47 })?;
48 let path = entry.path();
49 if path.extension().and_then(|e| e.to_str()) != Some("volume") {
50 continue;
51 }
52 let stem = match path.file_stem().and_then(|s| s.to_str()) {
53 Some(s) => s.to_string(),
54 None => continue,
55 };
56 let owner = match_owner(&stem, known_services);
57 out.push(VolumeRef {
58 name: format!("systemd-{stem}"),
59 quadlet_file: Some(path),
60 owner,
61 });
62 }
63 out.sort_by(|a, b| a.name.cmp(&b.name));
64 Ok(out)
65}
66
67pub fn match_owner(stem: &str, known_services: &[String]) -> Option<String> {
71 known_services
72 .iter()
73 .filter(|s| stem == s.as_str() || stem.starts_with(&format!("{s}-")))
74 .max_by_key(|s| s.len())
75 .cloned()
76}
77
78pub fn list_podman_volumes() -> Vec<String> {
82 let out = std::process::Command::new("podman")
83 .args(["volume", "ls", "--format", "{{.Name}}"])
84 .output();
85 match out {
86 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
87 .lines()
88 .map(|s| s.trim().to_string())
89 .filter(|s| !s.is_empty())
90 .collect(),
91 _ => Vec::new(),
92 }
93}
94
95pub fn reconcile(quadlet_refs: Vec<VolumeRef>, podman_names: Vec<String>) -> Vec<VolumeRef> {
99 let mut out: Vec<VolumeRef> = quadlet_refs;
100 let seen: std::collections::HashSet<String> = out.iter().map(|r| r.name.clone()).collect();
101 for name in podman_names {
102 if seen.contains(&name) {
103 continue;
104 }
105 out.push(VolumeRef {
109 name,
110 quadlet_file: None,
111 owner: None,
112 });
113 }
114 out.sort_by(|a, b| a.name.cmp(&b.name));
115 out
116}
117
118pub fn mountpoint_of(volume_name: &str) -> Option<PathBuf> {
121 let out = std::process::Command::new("podman")
122 .args([
123 "volume",
124 "inspect",
125 volume_name,
126 "--format",
127 "{{.Mountpoint}}",
128 ])
129 .output()
130 .ok()?;
131 if !out.status.success() {
132 return None;
133 }
134 let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
135 if s.is_empty() {
136 None
137 } else {
138 Some(PathBuf::from(s))
139 }
140}
141
142pub fn volume_size_bytes(volume_name: &str) -> Option<u64> {
153 let mp = mountpoint_of(volume_name)?;
154 let out = std::process::Command::new("podman")
155 .args(["unshare", "du", "-sb", "--"])
156 .arg(&mp)
157 .output()
158 .ok()?;
159 if !out.status.success() {
160 return None;
161 }
162 let stdout = String::from_utf8_lossy(&out.stdout);
164 stdout.split_whitespace().next()?.parse::<u64>().ok()
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use std::fs;
171
172 #[test]
173 fn match_owner_exact_and_prefix() {
174 let known = vec!["nextcloud".into(), "ente".into(), "caddy".into()];
175 assert_eq!(
176 match_owner("nextcloud", &known).as_deref(),
177 Some("nextcloud")
178 );
179 assert_eq!(
180 match_owner("nextcloud-db-data", &known).as_deref(),
181 Some("nextcloud")
182 );
183 assert_eq!(
184 match_owner("ente-minio-data", &known).as_deref(),
185 Some("ente")
186 );
187 assert_eq!(match_owner("unrelated-vol", &known), None);
188 }
189
190 #[test]
191 fn match_owner_longest_wins() {
192 let known = vec!["nextcloud".into(), "nextcloud-db".into()];
193 assert_eq!(
194 match_owner("nextcloud-db-data", &known).as_deref(),
195 Some("nextcloud-db")
196 );
197 }
198
199 #[test]
200 fn match_owner_no_false_prefix() {
201 let known = vec!["caddy".into()];
203 assert_eq!(match_owner("caddyfile-data", &known), None);
204 }
205
206 #[test]
207 fn parse_volume_quadlets_reads_volume_files() {
208 let dir = tempfile::tempdir().unwrap();
209 fs::write(dir.path().join("nextcloud-db-data.volume"), "[Volume]").unwrap();
210 fs::write(dir.path().join("ente-minio-data.volume"), "[Volume]").unwrap();
211 fs::write(dir.path().join("nextcloud.container"), "[Container]").unwrap(); let known = vec!["nextcloud".into(), "ente".into()];
214 let vols = parse_volume_quadlets(dir.path(), &known).unwrap();
215 assert_eq!(vols.len(), 2);
216 assert_eq!(vols[0].name, "systemd-ente-minio-data");
217 assert_eq!(vols[0].owner.as_deref(), Some("ente"));
218 assert_eq!(vols[1].name, "systemd-nextcloud-db-data");
219 assert_eq!(vols[1].owner.as_deref(), Some("nextcloud"));
220 assert!(vols[0].quadlet_file.is_some());
221 }
222}
223
224#[cfg(test)]
225mod tests_reconcile {
226 use super::*;
227
228 #[test]
229 fn reconcile_adds_podman_only_volumes() {
230 let quadlet = vec![VolumeRef {
231 name: "systemd-nextcloud-db-data".into(),
232 quadlet_file: Some("/fake/nextcloud-db-data.volume".into()),
233 owner: Some("nextcloud".into()),
234 }];
235 let podman = vec![
236 "systemd-nextcloud-db-data".to_string(), "systemd-ghost-volume".to_string(), ];
239 let merged = reconcile(quadlet, podman);
240 assert_eq!(merged.len(), 2);
241 let ghost = merged
242 .iter()
243 .find(|r| r.name == "systemd-ghost-volume")
244 .unwrap();
245 assert!(ghost.quadlet_file.is_none());
246 assert!(ghost.owner.is_none());
247 }
248}