use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VolumeRef {
pub name: String,
pub quadlet_file: Option<PathBuf>,
pub owner: Option<String>,
}
pub fn parse_volume_quadlets(
quadlet_dir: &Path,
known_services: &[String],
) -> Result<Vec<VolumeRef>> {
let mut out = Vec::new();
if !quadlet_dir.is_dir() {
return Ok(out);
}
for entry in std::fs::read_dir(quadlet_dir).map_err(|source| Error::FileRead {
path: quadlet_dir.to_path_buf(),
source,
})? {
let entry = entry.map_err(|source| Error::FileRead {
path: quadlet_dir.to_path_buf(),
source,
})?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("volume") {
continue;
}
let stem = match path.file_stem().and_then(|s| s.to_str()) {
Some(s) => s.to_string(),
None => continue,
};
let owner = match_owner(&stem, known_services);
out.push(VolumeRef {
name: format!("systemd-{stem}"),
quadlet_file: Some(path),
owner,
});
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
pub fn match_owner(stem: &str, known_services: &[String]) -> Option<String> {
known_services
.iter()
.filter(|s| stem == s.as_str() || stem.starts_with(&format!("{s}-")))
.max_by_key(|s| s.len())
.cloned()
}
pub fn list_podman_volumes() -> Vec<String> {
let out = std::process::Command::new("podman")
.args(["volume", "ls", "--format", "{{.Name}}"])
.output();
match out {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
_ => Vec::new(),
}
}
pub fn reconcile(quadlet_refs: Vec<VolumeRef>, podman_names: Vec<String>) -> Vec<VolumeRef> {
let mut out: Vec<VolumeRef> = quadlet_refs;
let seen: std::collections::HashSet<String> = out.iter().map(|r| r.name.clone()).collect();
for name in podman_names {
if seen.contains(&name) {
continue;
}
out.push(VolumeRef {
name,
quadlet_file: None,
owner: None,
});
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
pub fn mountpoint_of(volume_name: &str) -> Option<PathBuf> {
let out = std::process::Command::new("podman")
.args([
"volume",
"inspect",
volume_name,
"--format",
"{{.Mountpoint}}",
])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.is_empty() {
None
} else {
Some(PathBuf::from(s))
}
}
pub fn volume_size_bytes(volume_name: &str) -> Option<u64> {
let mp = mountpoint_of(volume_name)?;
let out = std::process::Command::new("podman")
.args(["unshare", "du", "-sb", "--"])
.arg(&mp)
.output()
.ok()?;
if !out.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&out.stdout);
stdout.split_whitespace().next()?.parse::<u64>().ok()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn match_owner_exact_and_prefix() {
let known = vec!["nextcloud".into(), "ente".into(), "caddy".into()];
assert_eq!(
match_owner("nextcloud", &known).as_deref(),
Some("nextcloud")
);
assert_eq!(
match_owner("nextcloud-db-data", &known).as_deref(),
Some("nextcloud")
);
assert_eq!(
match_owner("ente-minio-data", &known).as_deref(),
Some("ente")
);
assert_eq!(match_owner("unrelated-vol", &known), None);
}
#[test]
fn match_owner_longest_wins() {
let known = vec!["nextcloud".into(), "nextcloud-db".into()];
assert_eq!(
match_owner("nextcloud-db-data", &known).as_deref(),
Some("nextcloud-db")
);
}
#[test]
fn match_owner_no_false_prefix() {
let known = vec!["caddy".into()];
assert_eq!(match_owner("caddyfile-data", &known), None);
}
#[test]
fn parse_volume_quadlets_reads_volume_files() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("nextcloud-db-data.volume"), "[Volume]").unwrap();
fs::write(dir.path().join("ente-minio-data.volume"), "[Volume]").unwrap();
fs::write(dir.path().join("nextcloud.container"), "[Container]").unwrap();
let known = vec!["nextcloud".into(), "ente".into()];
let vols = parse_volume_quadlets(dir.path(), &known).unwrap();
assert_eq!(vols.len(), 2);
assert_eq!(vols[0].name, "systemd-ente-minio-data");
assert_eq!(vols[0].owner.as_deref(), Some("ente"));
assert_eq!(vols[1].name, "systemd-nextcloud-db-data");
assert_eq!(vols[1].owner.as_deref(), Some("nextcloud"));
assert!(vols[0].quadlet_file.is_some());
}
}
#[cfg(test)]
mod tests_reconcile {
use super::*;
#[test]
fn reconcile_adds_podman_only_volumes() {
let quadlet = vec![VolumeRef {
name: "systemd-nextcloud-db-data".into(),
quadlet_file: Some("/fake/nextcloud-db-data.volume".into()),
owner: Some("nextcloud".into()),
}];
let podman = vec![
"systemd-nextcloud-db-data".to_string(), "systemd-ghost-volume".to_string(), ];
let merged = reconcile(quadlet, podman);
assert_eq!(merged.len(), 2);
let ghost = merged
.iter()
.find(|r| r.name == "systemd-ghost-volume")
.unwrap();
assert!(ghost.quadlet_file.is_none());
assert!(ghost.owner.is_none());
}
}