use std::path::PathBuf;
use miette::{Result, bail, miette};
use tracing::{info, warn};
use super::{resolve::ResolvedCluster, sys};
const SNAPSHOT_INFIX: &str = "bestool-kopia-";
#[derive(Debug)]
pub struct Snapshot {
vg: String,
lv: String,
kopia_mount: PathBuf,
}
fn snapshot_name(pid: u32) -> String {
format!("{SNAPSHOT_INFIX}{pid}")
}
fn parse_vg_lv(output: &str) -> Result<(String, String)> {
let mut fields = output.split_whitespace();
match (fields.next(), fields.next()) {
(Some(vg), Some(lv)) => Ok((vg.to_owned(), lv.to_owned())),
_ => bail!("could not parse vg/lv from lvs output: {output:?}"),
}
}
fn mount_options(fstype: &str, idmap: &str) -> String {
let mut opts = vec!["ro".to_owned()];
if fstype == "xfs" {
opts.push("nouuid".to_owned());
}
opts.push(format!("X-mount.idmap={idmap}"));
opts.join(",")
}
pub async fn prepare(resolved: &ResolvedCluster, backup_type: &str) -> Result<(PathBuf, Snapshot)> {
let pid = std::process::id();
let base_mount = sys::findmnt_target(&resolved.data_dir).await?;
let rel = sys::relative_data_path(&resolved.data_dir, &base_mount)?;
let source = sys::findmnt_field("SOURCE", &resolved.data_dir).await?;
let fstype = sys::findmnt_field("FSTYPE", &resolved.data_dir).await?;
let (vg, lv) = parse_vg_lv(
&sys::capture("lvs", &["--noheadings", "-o", "vg_name,lv_name", &source]).await?,
)?;
let map = sys::postgres_to_kopia_idmap().await?;
let kopia_mount = super::stable_source_dir(backup_type);
reap_stale(&vg, &kopia_mount).await;
let snapshot_lv = snapshot_name(pid);
let mut snapshot = Snapshot {
vg: vg.clone(),
lv: String::new(),
kopia_mount: PathBuf::new(),
};
info!(vg = %vg, lv = %snapshot_lv, "creating thin-LVM snapshot");
sys::run_ok(
"lvcreate",
&["--snapshot", "--name", &snapshot_lv, &format!("{vg}/{lv}")],
)
.await?;
snapshot.lv = snapshot_lv.clone();
sys::run_ok(
"lvchange",
&["-ay", "-K", &format!("{vg}/{snapshot_lv}")],
)
.await?;
sys::mkdir(&kopia_mount).await?;
sys::run_ok(
"mount",
&[
&format!("/dev/{vg}/{snapshot_lv}"),
sys::path(&kopia_mount),
"-o",
&mount_options(&fstype, &map),
],
)
.await?;
snapshot.kopia_mount = kopia_mount.clone();
Ok((kopia_mount.join(rel), snapshot))
}
pub async fn teardown(snapshot: Snapshot) -> Result<()> {
if !snapshot.kopia_mount.as_os_str().is_empty() {
sys::umount(&snapshot.kopia_mount).await;
sys::rmdir(&snapshot.kopia_mount).await;
}
if !snapshot.lv.is_empty() {
let target = format!("{}/{}", snapshot.vg, snapshot.lv);
let _ = sys::run_ok("lvchange", &["-an", &target]).await;
let _ = sys::run_ok("lvremove", &["-f", &target])
.await
.map_err(|err| miette!("removing snapshot LV {target}: {err}"))
.inspect_err(|err| warn!("{err}"));
}
Ok(())
}
async fn reap_stale(vg: &str, kopia_mount: &std::path::Path) {
sys::umount(kopia_mount).await;
let Ok(list) = sys::capture("lvs", &["--noheadings", "-o", "lv_name", vg]).await else {
return;
};
for name in list.lines().map(str::trim).filter(|n| n.starts_with(SNAPSHOT_INFIX)) {
let target = format!("{vg}/{name}");
let _ = sys::run_ok("lvchange", &["-an", &target]).await;
let _ = sys::run_ok("lvremove", &["-f", &target]).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_vg_lv_from_padded_output() {
assert_eq!(
parse_vg_lv(" ubuntu-vg ubuntu-lv ").unwrap(),
("ubuntu-vg".to_owned(), "ubuntu-lv".to_owned())
);
assert!(parse_vg_lv("").is_err());
}
#[test]
fn mount_options_add_nouuid_only_for_xfs() {
assert_eq!(
mount_options("ext4", "u:1:2:1 g:3:4:1"),
"ro,X-mount.idmap=u:1:2:1 g:3:4:1"
);
assert_eq!(
mount_options("xfs", "u:1:2:1 g:3:4:1"),
"ro,nouuid,X-mount.idmap=u:1:2:1 g:3:4:1"
);
}
#[test]
fn snapshot_name_carries_reaper_infix() {
assert!(snapshot_name(99).starts_with(SNAPSHOT_INFIX));
}
}