use std::{
collections::BTreeMap,
path::{Path, PathBuf},
};
use miette::{Result, bail};
use serde::Deserialize;
#[derive(Debug)]
pub struct Prepared {
pub path: PathBuf,
pub extra_tags: BTreeMap<String, String>,
pub ignore: Vec<String>,
pub(super) teardown: Teardown,
}
#[derive(Debug)]
pub(super) enum Teardown {
Simple(super::simple::Cleanup),
Btrfs(super::postgresql::btrfs::Mounts),
Lvm(super::postgresql::lvm::Snapshot),
Vss(super::postgresql::vss::Shadow),
BaseBackup(PathBuf),
}
#[derive(Debug, Clone, Deserialize)]
pub struct SimpleConfig {
pub path: PathBuf,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PostgresqlConfig {
pub cluster: String,
#[serde(default)]
pub data_dir: Option<PathBuf>,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub connection_url: Option<String>,
#[serde(default)]
pub port: Option<u16>,
#[serde(default)]
pub socket: Option<PathBuf>,
#[serde(default)]
pub strategy: Option<String>,
}
#[derive(Debug, Clone)]
pub enum Method {
Simple(SimpleConfig),
Postgresql(PostgresqlConfig),
}
impl Method {
pub fn name(&self) -> &'static str {
match self {
Method::Simple(_) => "simple",
Method::Postgresql(_) => "postgresql",
}
}
pub async fn prepare(&self, backup_type: &str) -> Result<Prepared> {
match self {
Method::Simple(config) => {
let (path, cleanup) = super::simple::prepare(&config.path, backup_type).await?;
Ok(Prepared {
path,
extra_tags: BTreeMap::new(),
ignore: Vec::new(),
teardown: Teardown::Simple(cleanup),
})
}
Method::Postgresql(config) => super::postgresql::prepare(config, backup_type).await,
}
}
pub async fn cleanup(&self, prepared: Prepared) -> Result<()> {
match prepared.teardown {
Teardown::Simple(cleanup) => super::simple::teardown(cleanup).await,
Teardown::Btrfs(mounts) => super::postgresql::btrfs::teardown(mounts).await,
Teardown::Lvm(snapshot) => super::postgresql::lvm::teardown(snapshot).await,
Teardown::Vss(shadow) => super::postgresql::vss::teardown(shadow).await,
Teardown::BaseBackup(root) => super::postgresql::basebackup::teardown(root).await,
}
}
pub fn staging_dir(&self, target_override: Option<&Path>, pid: u32) -> PathBuf {
let parent = match self {
Method::Simple(config) => target_override
.map(Path::to_path_buf)
.unwrap_or_else(|| config.path.clone())
.parent()
.map(Path::to_path_buf),
Method::Postgresql(config) => super::postgresql::resolve::resolve_target(config)
.ok()
.and_then(|r| r.data_dir.parent().map(Path::to_path_buf)),
};
parent
.unwrap_or_else(std::env::temp_dir)
.join(format!(".bestool-restore.{pid}"))
}
pub async fn restore(&self, staging: &Path, opts: &RestoreOpts) -> Result<()> {
match self {
Method::Simple(config) => {
let target = opts.target.clone().unwrap_or_else(|| config.path.clone());
ensure_not_clobbering(&target, opts.clobber)?;
replace_dir(staging, &target).await
}
Method::Postgresql(config) => super::postgresql::restore(config, staging, opts).await,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct RestoreOpts {
pub target: Option<PathBuf>,
pub clobber: bool,
}
pub fn ensure_not_clobbering(target: &Path, clobber: bool) -> Result<()> {
if clobber || !dir_has_entries(target) {
return Ok(());
}
bail!(
"{} already contains data; refusing to overwrite without confirmation \
(pass --clobber-existing-data-yes-i-am-sure, or confirm interactively)",
target.display()
);
}
pub fn dir_has_entries(path: &Path) -> bool {
std::fs::read_dir(path)
.map(|mut it| it.next().is_some())
.unwrap_or(false)
}
pub(super) async fn replace_dir(staging: &Path, target: &Path) -> Result<()> {
use miette::{Context as _, IntoDiagnostic as _};
if target.exists() {
let backup = with_extension_suffix(target, "old");
if backup.exists() {
tokio::fs::remove_dir_all(&backup)
.await
.into_diagnostic()
.wrap_err_with(|| format!("removing stale {}", backup.display()))?;
}
tokio::fs::rename(target, &backup)
.await
.into_diagnostic()
.wrap_err_with(|| format!("moving {} aside to {}", target.display(), backup.display()))?;
}
if let Some(parent) = target.parent() {
tokio::fs::create_dir_all(parent).await.ok();
}
tokio::fs::rename(staging, target)
.await
.into_diagnostic()
.wrap_err_with(|| format!("moving restored data into {}", target.display()))
}
pub(super) fn with_extension_suffix(path: &Path, suffix: &str) -> PathBuf {
let mut name = path.file_name().unwrap_or_default().to_os_string();
name.push(".");
name.push(suffix);
path.with_file_name(name)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(not(target_os = "linux"))]
#[tokio::test]
async fn simple_prepare_returns_its_path_and_no_tags() {
let method = Method::Simple(SimpleConfig {
path: PathBuf::from("/data/custom"),
});
let prepared = method.prepare("custom").await.unwrap();
assert_eq!(prepared.path, PathBuf::from("/data/custom"));
assert!(prepared.extra_tags.is_empty());
assert!(prepared.ignore.is_empty());
method.cleanup(prepared).await.unwrap();
}
#[test]
fn clobber_guard_blocks_occupied_dir_unless_forced() {
let tmp = tempfile::tempdir().unwrap();
let occupied = tmp.path().join("data");
std::fs::create_dir_all(&occupied).unwrap();
std::fs::write(occupied.join("PG_VERSION"), "16").unwrap();
assert!(ensure_not_clobbering(&occupied, false).is_err());
assert!(ensure_not_clobbering(&occupied, true).is_ok());
assert!(ensure_not_clobbering(&tmp.path().join("absent"), false).is_ok());
let empty = tmp.path().join("empty");
std::fs::create_dir_all(&empty).unwrap();
assert!(ensure_not_clobbering(&empty, false).is_ok());
}
#[test]
fn extension_suffix_appends() {
assert_eq!(
with_extension_suffix(Path::new("/var/lib/postgresql/16/main"), "old"),
PathBuf::from("/var/lib/postgresql/16/main.old")
);
}
#[tokio::test]
async fn replace_dir_keeps_old_and_moves_in() {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join("data");
std::fs::create_dir_all(&target).unwrap();
std::fs::write(target.join("old-marker"), "x").unwrap();
let staging = tmp.path().join("staging");
std::fs::create_dir_all(&staging).unwrap();
std::fs::write(staging.join("new-marker"), "y").unwrap();
replace_dir(&staging, &target).await.unwrap();
assert!(target.join("new-marker").exists());
assert!(!target.join("old-marker").exists());
assert!(tmp.path().join("data.old").join("old-marker").exists());
}
}