use std::path::{Path, PathBuf};
use jiff::Timestamp;
use miette::{Context as _, IntoDiagnostic as _, Result, miette};
use tracing::{info, warn};
use super::{
super::hold::{HeldCapture, release},
resolve::ResolvedCluster,
sys,
};
const TOPLEVEL_MOUNT_DIR: &str = "/var/lib/bestool";
const TOPLEVEL_MOUNT_PREFIX: &str = "/var/lib/bestool/bestool-btrfs-toplevel";
const SNAPSHOT_INFIX: &str = "bestool-kopia-";
const HELD_INFIX: &str = "bestool-held-";
const HELD_TOPLEVEL_PREFIX: &str = "/var/lib/bestool/bestool-btrfs-held";
#[derive(Debug)]
pub struct Mounts {
toplevel_mount: PathBuf,
snapshot_path: PathBuf,
kopia_mount: PathBuf,
fsdev: String,
idmap: String,
}
fn stable_kopia_mount(backup_type: &str) -> PathBuf {
super::stable_source_dir(backup_type)
}
fn snapshot_name(token: &str) -> String {
format!("{SNAPSHOT_INFIX}{token}")
}
fn toplevel_mount(token: &str) -> PathBuf {
PathBuf::from(format!("{TOPLEVEL_MOUNT_PREFIX}.{token}"))
}
pub async fn prepare(
resolved: &ResolvedCluster,
backup_type: &str,
) -> Result<(PathBuf, Timestamp, Mounts)> {
let token = sys::run_token();
let base_mount = sys::findmnt_target(&resolved.data_dir).await?;
let rel = sys::relative_data_path(&resolved.data_dir, &base_mount)?;
let fsdev = format!(
"/dev/disk/by-uuid/{}",
sys::findmnt_field("UUID", &resolved.data_dir).await?
);
let map = sys::postgres_to_kopia_idmap().await?;
let kopia_mount = stable_kopia_mount(backup_type);
reap_stale(&fsdev, &kopia_mount).await;
let toplevel_mount = toplevel_mount(&token);
let snapshot_name = snapshot_name(&token);
let snapshot_path = toplevel_mount.join(&snapshot_name);
let mut mounts = Mounts {
toplevel_mount: toplevel_mount.clone(),
snapshot_path: PathBuf::new(),
kopia_mount: PathBuf::new(),
fsdev: fsdev.clone(),
idmap: map.clone(),
};
sys::mkdir(&toplevel_mount).await?;
sys::run_ok(
"mount",
&["-o", "subvolid=5", &fsdev, sys::path(&toplevel_mount)],
)
.await?;
info!(snapshot = %snapshot_path.display(), "creating read-only btrfs snapshot");
sys::run_ok(
"btrfs",
&[
"subvolume",
"snapshot",
"-r",
sys::path(&base_mount),
sys::path(&snapshot_path),
],
)
.await?;
let taken_at = Timestamp::now();
mounts.snapshot_path = snapshot_path.clone();
sys::mkdir(&kopia_mount).await?;
if let Some(parent) = kopia_mount.parent() {
sys::make_traversable(parent).await?;
}
sys::run_ok(
"mount",
&[
&fsdev,
sys::path(&kopia_mount),
"-o",
&format!("subvol={snapshot_name},X-mount.idmap={map}"),
],
)
.await?;
mounts.kopia_mount = kopia_mount.clone();
Ok((kopia_mount.join(rel), taken_at, mounts))
}
pub async fn teardown(mounts: Mounts) -> Result<()> {
if !mounts.kopia_mount.as_os_str().is_empty() {
sys::umount(&mounts.kopia_mount).await;
sys::rmdir(&mounts.kopia_mount).await;
}
if !mounts.snapshot_path.as_os_str().is_empty() {
let _ = sys::run_ok(
"btrfs",
&["subvolume", "delete", sys::path(&mounts.snapshot_path)],
)
.await
.map_err(|err| miette!("deleting snapshot subvolume: {err}"))
.inspect_err(|err| warn!("{err}"));
}
if !mounts.toplevel_mount.as_os_str().is_empty() {
sys::umount(&mounts.toplevel_mount).await;
sys::rmdir(&mounts.toplevel_mount).await;
}
Ok(())
}
fn held_toplevel_mount(id: &str) -> PathBuf {
PathBuf::from(format!("{HELD_TOPLEVEL_PREFIX}.{id}"))
}
fn held_snapshot_name(id: &str) -> String {
format!("{HELD_INFIX}{id}")
}
pub async fn hold(mounts: Mounts, id: &str, source: &Path) -> Result<(PathBuf, HeldCapture)> {
let rel = source
.strip_prefix(&mounts.kopia_mount)
.map_err(|_| {
miette!(
"{} is not under the capture's mount {}",
source.display(),
mounts.kopia_mount.display()
)
})?
.to_path_buf();
let run_name = mounts
.snapshot_path
.file_name()
.ok_or_else(|| miette!("the capture has no snapshot subvolume to hold"))?
.to_owned();
let held_toplevel = held_toplevel_mount(id);
let held_name = held_snapshot_name(id);
let held_snapshot = held_toplevel.join(&held_name);
let held_mount = super::super::hold::hold_source_dir(id);
sys::mkdir(&held_toplevel).await?;
sys::run_ok(
"mount",
&[
"-o",
"subvolid=5",
&mounts.fsdev,
sys::path(&held_toplevel),
],
)
.await?;
let from = held_toplevel.join(&run_name);
if let Err(err) = tokio::fs::rename(&from, &held_snapshot).await {
sys::umount(&held_toplevel).await;
sys::rmdir(&held_toplevel).await;
return Err(err)
.into_diagnostic()
.wrap_err_with(|| format!("renaming {} to {}", from.display(), held_snapshot.display()));
}
info!(hold = %id, snapshot = %held_snapshot.display(), "held btrfs snapshot");
sys::umount(&mounts.kopia_mount).await;
sys::rmdir(&mounts.kopia_mount).await;
sys::umount(&mounts.toplevel_mount).await;
sys::rmdir(&mounts.toplevel_mount).await;
let capture = HeldCapture::Btrfs {
toplevel_mount: held_toplevel,
snapshot_path: held_snapshot,
mount: held_mount.clone(),
};
sys::mkdir(&held_mount).await?;
if let Some(parent) = held_mount.parent() {
sys::make_traversable(parent).await?;
}
if let Err(err) = sys::run_ok(
"mount",
&[
&mounts.fsdev,
sys::path(&held_mount),
"-o",
&format!("subvol={held_name},X-mount.idmap={}", mounts.idmap),
],
)
.await
{
let _ = release(&capture).await;
return Err(err).wrap_err("mounting the held snapshot");
}
Ok((held_mount.join(rel), capture))
}
pub async fn release_held(toplevel_mount: &Path, snapshot_path: &Path, mount: &Path) -> Result<()> {
teardown(Mounts {
toplevel_mount: toplevel_mount.to_path_buf(),
snapshot_path: snapshot_path.to_path_buf(),
kopia_mount: mount.to_path_buf(),
fsdev: String::new(),
idmap: String::new(),
})
.await
}
async fn reap_stale(fsdev: &str, kopia_mount: &Path) {
sys::umount(kopia_mount).await;
if let Ok(entries) = sys::glob_prefix(TOPLEVEL_MOUNT_DIR, "bestool-btrfs-toplevel.") {
for stale in entries {
sys::umount(&stale).await;
sys::rmdir(&stale).await;
}
}
let reap_mount = PathBuf::from(format!("{TOPLEVEL_MOUNT_PREFIX}.reap-{}", sys::run_token()));
if sys::mkdir(&reap_mount).await.is_ok()
&& sys::run_ok("mount", &["-o", "subvolid=5", fsdev, sys::path(&reap_mount)])
.await
.is_ok()
{
if let Ok(subs) = sys::glob_prefix(sys::path(&reap_mount), SNAPSHOT_INFIX) {
for sub in subs {
let _ = sys::run_ok("btrfs", &["subvolume", "delete", sys::path(&sub)]).await;
}
}
sys::umount(&reap_mount).await;
}
sys::rmdir(&reap_mount).await;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn snapshot_name_carries_reaper_infix() {
let name = snapshot_name("deadbeef");
assert_eq!(name, "bestool-kopia-deadbeef");
assert!(name.starts_with(SNAPSHOT_INFIX));
}
#[test]
fn held_names_are_outside_the_reapers_globs() {
let name = held_snapshot_name("tamanu-postgres-20260814T054412Z");
assert!(
!name.starts_with(SNAPSHOT_INFIX),
"{name} would be deleted by the reaper"
);
let mount = held_toplevel_mount("tamanu-postgres-20260814T054412Z");
let swept = format!("{TOPLEVEL_MOUNT_PREFIX}.");
assert!(
!mount.to_string_lossy().starts_with(&swept),
"{} would be unmounted by the reaper",
mount.display()
);
assert!(mount.starts_with(TOPLEVEL_MOUNT_DIR));
}
#[test]
fn held_mounts_are_keyed_by_hold_not_by_backup_type() {
let one = held_toplevel_mount("tamanu-postgres-20260814T054412Z");
let two = held_toplevel_mount("tamanu-postgres-20260814T060000Z");
assert_ne!(one, two, "two holds of one type must not collide");
}
#[test]
fn stable_mount_is_per_type_and_fixed() {
assert_eq!(
stable_kopia_mount("tamanu-postgres"),
super::super::stable_source_dir("tamanu-postgres")
);
}
#[test]
fn toplevel_mount_path() {
assert_eq!(
toplevel_mount("cafef00d"),
PathBuf::from("/var/lib/bestool/bestool-btrfs-toplevel.cafef00d")
);
}
#[test]
fn reaper_glob_matches_toplevel_and_reap_mounts() {
let file = |p: &PathBuf| p.file_name().unwrap().to_string_lossy().into_owned();
assert!(file(&toplevel_mount("abc")).starts_with("bestool-btrfs-toplevel."));
let reap = PathBuf::from(format!("{TOPLEVEL_MOUNT_PREFIX}.reap-abc"));
assert!(file(&reap).starts_with("bestool-btrfs-toplevel."));
}
}