pub mod basebackup;
pub mod btrfs;
pub mod lvm;
pub mod resolve;
mod service;
pub mod space;
pub mod strategy;
mod sys;
pub mod usn;
#[cfg(windows)]
pub mod vss;
use std::{
collections::BTreeMap,
path::{Path, PathBuf},
};
use miette::{Context as _, IntoDiagnostic as _, Result, bail};
use tracing::{info, warn};
use self::strategy::Strategy;
use super::method::{PostgresqlConfig, Prepared, Teardown};
pub(super) fn stable_source_dir(backup_type: &str) -> PathBuf {
#[cfg(unix)]
{
PathBuf::from("/var/lib/bestool/backup-source").join(backup_type)
}
#[cfg(not(unix))]
{
let base = std::env::var_os("ProgramData")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(r"C:\ProgramData"));
base.join("bestool").join("backup-source").join(backup_type)
}
}
fn ignore_globs() -> Vec<String> {
["postmaster.pid", "*.log", "pg_stat_tmp/*", "lost+found"]
.into_iter()
.map(String::from)
.collect()
}
fn metadata_tags(resolved: &resolve::ResolvedCluster, strategy: Strategy) -> BTreeMap<String, String> {
BTreeMap::from([
("pg-version".to_owned(), resolved.version.clone()),
("pg-cluster".to_owned(), resolved.cluster.clone()),
("pg-strategy".to_owned(), format!("{strategy:?}").to_lowercase()),
])
}
pub async fn prepare(config: &PostgresqlConfig, backup_type: &str) -> Result<Prepared> {
let resolved = resolve::resolve(config)?;
let strategy = strategy::detect(config.strategy.as_deref(), &resolved.data_dir)?;
info!(
cluster = %resolved.cluster,
version = %resolved.version,
?strategy,
data_dir = %resolved.data_dir.display(),
"preparing postgresql backup",
);
checkpoint(config, &resolved.data_dir).await;
let need = match strategy {
Strategy::BaseBackup | Strategy::Vss => {
space::estimate_needed(config, &resolved.data_dir).await
}
_ => None,
};
match strategy {
Strategy::BaseBackup => basebackup_prepared(&resolved, backup_type, config, need).await,
snapshot => match snapshot_prepared(snapshot, &resolved, backup_type, need).await {
Ok(prepared) => Ok(prepared),
Err(err) => {
warn!(
strategy = ?snapshot,
"snapshot backend unavailable ({}); falling back to pg_basebackup",
super::trim_error(&err)
);
basebackup_prepared(&resolved, backup_type, config, need).await
}
},
}
}
async fn snapshot_prepared(
strategy: Strategy,
resolved: &resolve::ResolvedCluster,
backup_type: &str,
need: Option<u64>,
) -> Result<Prepared> {
let (path, taken_at, teardown, volume) = match strategy {
Strategy::Btrfs => {
let (path, taken_at, mounts) = btrfs::prepare(resolved, backup_type).await?;
(path, taken_at, Teardown::Btrfs(mounts), None)
}
Strategy::ThinLvm => {
let (path, taken_at, snapshot) = lvm::prepare(resolved, backup_type).await?;
(path, taken_at, Teardown::Lvm(snapshot), None)
}
#[cfg(windows)]
Strategy::Vss => {
let (path, taken_at, shadow) = vss::prepare(resolved, backup_type, need).await?;
let volume = vss::volume_capture(&shadow, taken_at);
(path, taken_at, Teardown::Vss(shadow), volume)
}
#[cfg(not(windows))]
Strategy::Vss => {
let _ = need;
unreachable!("VSS is only detected on Windows")
}
Strategy::BaseBackup => unreachable!("basebackup is handled by the caller"),
};
Ok(Prepared {
path,
taken_at: Some(taken_at),
extra_tags: metadata_tags(resolved, strategy),
ignore: ignore_globs(),
teardown,
volume,
})
}
async fn basebackup_prepared(
resolved: &resolve::ResolvedCluster,
backup_type: &str,
config: &PostgresqlConfig,
need: Option<u64>,
) -> Result<Prepared> {
let (path, root) = basebackup::prepare(resolved, backup_type, config, need).await?;
Ok(Prepared {
path,
taken_at: None,
extra_tags: metadata_tags(resolved, Strategy::BaseBackup),
ignore: ignore_globs(),
teardown: Teardown::BaseBackup(root),
volume: None,
})
}
pub async fn restore(
config: &PostgresqlConfig,
staging: &Path,
opts: &super::method::RestoreOpts,
) -> Result<()> {
let plan = resolve::plan_restore(staging, config)?;
let target = &plan.target;
info!(
cluster = %target.cluster,
version = %target.version,
dest = %plan.dest.display(),
whole_install = plan.whole_install,
"restoring postgres cluster",
);
super::method::ensure_not_clobbering(&plan.dest, opts.clobber)?;
stop_the_cluster(config, &plan).await?;
crate::interactive::retry("moving the restored data into place", async || {
super::method::replace_dir(&plan.source, &plan.dest).await
})
.await?;
start_the_cluster(config, &plan).await?;
verify(config, &target.data_dir, &target.version).await;
info!("restore complete; run migrations / config sync as needed");
Ok(())
}
async fn stop_the_cluster(config: &PostgresqlConfig, plan: &resolve::RestorePlan) -> Result<()> {
let target = &plan.target;
if !plan.whole_install {
let major = plan.data_major.clone();
crate::interactive::retry("checking the installed postgres version", async || {
resolve::ensure_server_version_available(&major)
})
.await?;
}
crate::interactive::retry("stopping the postgres cluster", async || {
service::stop(target, config).await
})
.await?;
service::quiesce_other_versions(&target.version).await;
Ok(())
}
async fn start_the_cluster(config: &PostgresqlConfig, plan: &resolve::RestorePlan) -> Result<()> {
let target = &plan.target;
#[cfg(feature = "canopy-restore")]
crate::actions::canopy::restore::inplace::Interlock::ensure_clear(&target.data_dir).await?;
let service_account = service::service_account(target, config).await;
crate::interactive::retry("fixing restored data permissions", async || {
fix_ownership(&plan.dest, service_account.as_deref()).await
})
.await?;
crate::interactive::retry_or_recover(
"starting the postgres cluster",
"reset the write-ahead log",
"force-reset the WAL so the cluster can start without replaying it — \
destructive: can discard recent transactions or corrupt an \
otherwise-healthy cluster; only sound for a backup that won't start any \
other way",
async || service::start(target, config).await,
async || pg_resetwal(&target.data_dir, &target.version).await,
)
.await?;
repoint_current_symlinks(target).await;
Ok(())
}
#[cfg(unix)]
async fn repoint_current_symlinks(target: &resolve::ResolvedCluster) {
repoint_symlink_if_present(&resolve::postgres_base().join("current"), &target.data_dir).await;
let etc_version = PathBuf::from("/etc/postgresql").join(&target.version);
if etc_version.is_dir() {
repoint_symlink_if_present(Path::new("/etc/postgresql/current"), &etc_version).await;
}
}
#[cfg(not(unix))]
async fn repoint_current_symlinks(_target: &resolve::ResolvedCluster) {}
#[cfg(unix)]
async fn repoint_symlink_if_present(link: &Path, dest: &Path) {
match tokio::fs::symlink_metadata(link).await {
Ok(meta) if meta.file_type().is_symlink() => {}
_ => return, }
let staged = link.with_extension("bestool-current");
let _ = tokio::fs::remove_file(&staged).await;
if let Err(err) = tokio::fs::symlink(dest, &staged).await {
warn!("could not stage symlink {} -> {}: {err}", staged.display(), dest.display());
return;
}
if let Err(err) = tokio::fs::rename(&staged, link).await {
warn!("could not repoint {} -> {}: {err}", link.display(), dest.display());
let _ = tokio::fs::remove_file(&staged).await;
} else {
info!("repointed {} -> {}", link.display(), dest.display());
}
}
#[cfg(unix)]
async fn fix_ownership(dest: &Path, _account: Option<&str>) -> Result<()> {
run_status("chown", &["-R", "postgres:postgres", path(dest)]).await?;
run_status("chmod", &["0750", path(dest)]).await
}
#[cfg(windows)]
async fn fix_ownership(dest: &Path, account: Option<&str>) -> Result<()> {
let dir = dest.to_string_lossy();
run_status("takeown", &["/F", dir.as_ref(), "/R", "/D", "Y"]).await?;
run_status("icacls", &[dir.as_ref(), "/reset", "/T", "/C", "/Q"]).await?;
if let Some(account) = account {
let grant = format!("{account}:(OI)(CI)F");
run_status("icacls", &[dir.as_ref(), "/grant", grant.as_str(), "/T", "/C", "/Q"]).await?;
}
Ok(())
}
#[cfg(not(any(unix, windows)))]
async fn fix_ownership(_dest: &Path, _account: Option<&str>) -> Result<()> {
Ok(())
}
async fn pg_resetwal(data_dir: &Path, major: &str) -> Result<()> {
let mut cmd = pg_command(&postgres_bin_versioned("pg_resetwal", major, data_dir));
cmd.arg("-f").arg(data_dir);
run_checked(cmd, "pg_resetwal").await
}
async fn verify(config: &PostgresqlConfig, data_dir: &Path, major: &str) {
let mut cmd = pg_command(&postgres_bin_versioned("psql", major, data_dir));
cmd.args(["-X", "-q", "-w", "-tAc", "SELECT 1"]);
apply_connection(&mut cmd, config);
cmd.stdin(std::process::Stdio::null());
match cmd.status().await {
Ok(s) if s.success() => info!("restored cluster accepts connections"),
Ok(s) => warn!(%s, "post-restore verification query failed"),
Err(err) => warn!("could not run verification query: {err}"),
}
}
#[cfg(unix)]
fn path(p: &Path) -> &str {
p.to_str().unwrap_or_default()
}
#[cfg(any(unix, windows))]
pub(super) async fn run_status(program: &str, args: &[&str]) -> Result<()> {
let status = tokio::process::Command::new(program)
.args(args)
.stdin(std::process::Stdio::null())
.status()
.await
.into_diagnostic()
.wrap_err_with(|| format!("spawning {program}"))?;
if !status.success() {
bail!("{program} {} failed ({status})", args.join(" "));
}
Ok(())
}
pub(super) fn postgres_bin(name: &str, data_dir: &Path) -> String {
#[cfg(windows)]
if let Some(candidate) = bin_beside_data_dir(name, data_dir).filter(|p| p.is_file()) {
return candidate.to_string_lossy().into_owned();
}
#[cfg(not(windows))]
let _ = data_dir;
crate::find_postgres::find_postgres_bin(name)
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| name.to_owned())
}
fn postgres_bin_versioned(name: &str, major: &str, data_dir: &Path) -> String {
#[cfg(unix)]
{
let candidate = PathBuf::from("/usr/lib/postgresql")
.join(major)
.join("bin")
.join(name);
if candidate.is_file() {
return candidate.to_string_lossy().into_owned();
}
}
#[cfg(not(unix))]
let _ = major;
postgres_bin(name, data_dir)
}
#[cfg(any(windows, test))]
fn bin_beside_data_dir(name: &str, data_dir: &Path) -> Option<PathBuf> {
let exe = if cfg!(windows) {
format!("{name}.exe")
} else {
name.to_owned()
};
data_dir.parent().map(|p| p.join("bin").join(exe))
}
pub(super) fn pg_command(bin: &str) -> tokio::process::Command {
#[cfg(unix)]
{
let mut cmd = tokio::process::Command::new("sudo");
cmd.args(["-u", "postgres", bin]);
cmd
}
#[cfg(not(unix))]
{
tokio::process::Command::new(bin)
}
}
pub(super) fn apply_connection(cmd: &mut tokio::process::Command, config: &PostgresqlConfig) {
if let Some(url) = &config.connection_url {
cmd.arg("-d").arg(url);
return;
}
if let Some(socket) = &config.socket {
cmd.arg("-h").arg(socket);
}
if let Some(port) = config.port {
cmd.arg("-p").arg(port.to_string());
}
}
async fn run_checked(mut cmd: tokio::process::Command, what: &str) -> Result<()> {
let status = cmd
.stdin(std::process::Stdio::null())
.status()
.await
.into_diagnostic()
.wrap_err_with(|| format!("spawning {what}"))?;
if !status.success() {
bail!("{what} failed ({status})");
}
Ok(())
}
async fn checkpoint(config: &PostgresqlConfig, data_dir: &Path) {
let mut cmd = pg_command(&postgres_bin("psql", data_dir));
cmd.args(["-X", "-q", "-w"]);
apply_connection(&mut cmd, config);
cmd.args(["-c", "CHECKPOINT;"]);
cmd.stdin(std::process::Stdio::null());
match cmd.status().await {
Ok(status) if status.success() => info!("issued CHECKPOINT before snapshot"),
Ok(status) => warn!(
%status,
"CHECKPOINT failed; snapshot is still crash-consistent, recovery may just replay more WAL"
),
Err(err) => warn!("could not run CHECKPOINT (continuing): {err}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[tokio::test]
async fn repoint_symlink_updates_an_existing_symlink() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("18");
let new = tmp.path().join("17");
std::fs::create_dir_all(&old).unwrap();
std::fs::create_dir_all(&new).unwrap();
let link = tmp.path().join("current");
std::os::unix::fs::symlink(&old, &link).unwrap();
repoint_symlink_if_present(&link, &new).await;
assert_eq!(std::fs::read_link(&link).unwrap(), new);
}
#[cfg(unix)]
#[tokio::test]
async fn repoint_symlink_leaves_a_non_symlink_alone() {
let tmp = tempfile::tempdir().unwrap();
let real = tmp.path().join("current");
std::fs::create_dir_all(&real).unwrap();
let dest = tmp.path().join("target");
std::fs::create_dir_all(&dest).unwrap();
repoint_symlink_if_present(&real, &dest).await;
assert!(real.is_dir());
assert!(!std::fs::symlink_metadata(&real).unwrap().file_type().is_symlink());
}
#[test]
fn bin_beside_data_dir_is_sibling_of_data() {
let candidate = bin_beside_data_dir("pg_basebackup", Path::new("/opt/pg/16/data")).unwrap();
let name = if cfg!(windows) {
"pg_basebackup.exe"
} else {
"pg_basebackup"
};
assert_eq!(candidate, Path::new("/opt/pg/16/bin").join(name));
}
#[test]
fn ignore_globs_never_include_required_dirs() {
let globs = ignore_globs();
assert!(globs.contains(&"postmaster.pid".to_owned()));
for required in ["pg_wal", "pg_xact", "pg_control", "global"] {
assert!(
!globs.iter().any(|g| g.contains(required)),
"{required} must never be ignored"
);
}
}
fn pg_config(connection_url: Option<&str>, socket: Option<&str>, port: Option<u16>) -> PostgresqlConfig {
PostgresqlConfig {
cluster: "main".into(),
data_dir: None,
version: None,
connection_url: connection_url.map(str::to_owned),
port,
socket: socket.map(PathBuf::from),
strategy: None,
staging_dir: None,
service_name: None,
}
}
#[test]
fn apply_connection_prefers_the_url() {
let mut cmd = tokio::process::Command::new("psql");
apply_connection(&mut cmd, &pg_config(Some("postgresql://u:p@h/db"), Some("/run/pg"), Some(5433)));
let args: Vec<_> = cmd
.as_std()
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_eq!(args, vec!["-d", "postgresql://u:p@h/db"]);
}
#[test]
fn apply_connection_falls_back_to_socket_and_port() {
let mut cmd = tokio::process::Command::new("psql");
apply_connection(&mut cmd, &pg_config(None, Some("/run/pg"), Some(5433)));
let args: Vec<_> = cmd
.as_std()
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_eq!(args, vec!["-h", "/run/pg", "-p", "5433"]);
}
#[test]
fn metadata_tags_carry_version_cluster_strategy() {
let resolved = resolve::ResolvedCluster {
data_dir: "/var/lib/postgresql/16/main".into(),
version: "16".into(),
cluster: "main".into(),
};
let tags = metadata_tags(&resolved, Strategy::Btrfs);
assert_eq!(tags.get("pg-version").map(String::as_str), Some("16"));
assert_eq!(tags.get("pg-cluster").map(String::as_str), Some("main"));
assert_eq!(tags.get("pg-strategy").map(String::as_str), Some("btrfs"));
}
}
#[cfg(feature = "canopy-restore")]
pub async fn restore_in_place(
config: &PostgresqlConfig,
record: &super::hold::HoldRecord,
capture: &Path,
opts: &super::method::RestoreOpts,
) -> Result<()> {
use crate::actions::canopy::restore::inplace::{Interlock, Job};
let plan = resolve::plan_restore(capture, config)?;
let target = &plan.target;
info!(
cluster = %target.cluster,
version = %target.version,
dest = %plan.dest.display(),
whole_install = plan.whole_install,
hold = %record.id,
"restoring postgres cluster in place from a held capture",
);
let resuming = Interlock::resumes(&target.data_dir, record).await;
if !resuming || opts.declined {
super::method::ensure_not_clobbering_in_place(&plan.dest, opts.clobber)?;
}
stop_the_cluster(config, &plan).await?;
let planned = super::super::restore::inplace::plan(Job {
record,
capture: &plan.source,
live: &plan.dest,
skip: interlock_skips(&plan)?,
})
.await
.wrap_err_with(|| {
format!(
"nothing was written, and the cluster at {} is stopped but intact; \
start it again, or resolve this and re-run the restore",
target.data_dir.display(),
)
})?;
let version_file = target.data_dir.join("PG_VERSION");
let parked = super::method::with_extension_suffix(&version_file, PARKED_SUFFIX);
let unfinished = resuming || tokio::fs::symlink_metadata(&parked).await.is_ok();
let summary = if planned.has_work() || unfinished {
let interlock = Interlock::engage(&target.data_dir, record).await?;
park_version_file(&version_file, &parked).await?;
let summary = super::super::restore::inplace::lay_down(planned)
.await
.wrap_err_with(|| {
format!(
"the cluster at {} is part-way through a restore from hold {} and will \
not start; the hold is untouched, so run the same command again to \
finish it",
target.data_dir.display(),
record.id,
)
})?;
sync_version_file(&resolve::locate_pgdata(capture)?, &version_file, &parked).await?;
interlock.release().await?;
summary
} else {
super::super::restore::inplace::lay_down(planned).await?
};
info!(
copied = summary.copied,
removed = summary.removed,
bytes = summary.bytes,
from_filesystem = summary.from_filesystem,
"the divergence from the held capture is laid down",
);
start_the_cluster(config, &plan).await?;
verify(config, &target.data_dir, &target.version).await;
info!("in-place restore complete; run migrations / config sync as needed");
Ok(())
}
#[cfg(feature = "canopy-restore")]
const PARKED_SUFFIX: &str = "bestool-restoring";
#[cfg(feature = "canopy-restore")]
fn interlock_skips(plan: &resolve::RestorePlan) -> Result<Vec<PathBuf>> {
use crate::actions::canopy::restore::inplace::Interlock;
let within = plan.target.data_dir.strip_prefix(&plan.dest).map_err(|_| {
miette::miette!(
"the data directory {} is not inside the tree being restored ({})",
plan.target.data_dir.display(),
plan.dest.display(),
)
})?;
Ok(vec![
within.join("PG_VERSION"),
within.join(format!("PG_VERSION.{PARKED_SUFFIX}")),
within.join(Interlock::marker_name()),
])
}
#[cfg(feature = "canopy-restore")]
async fn park_version_file(version_file: &Path, parked: &Path) -> Result<()> {
if tokio::fs::metadata(parked).await.is_ok() {
match tokio::fs::remove_file(version_file).await {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => {
return Err(err).into_diagnostic().wrap_err_with(|| {
format!(
"{} could not be moved out of the way, so the cluster would stay \
startable while it is being written over",
version_file.display()
)
});
}
}
return Ok(());
}
match tokio::fs::rename(version_file, parked).await {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err)
.into_diagnostic()
.wrap_err_with(|| format!("moving {} out of the way", version_file.display())),
}
}
#[cfg(feature = "canopy-restore")]
async fn sync_version_file(capture_data: &Path, version_file: &Path, parked: &Path) -> Result<()> {
let from = capture_data.join("PG_VERSION");
crate::actions::canopy::restore::sync::copy_entry(&from, version_file, false)
.await
.wrap_err_with(|| {
format!(
"writing {} back from the capture, without which the cluster cannot start",
version_file.display()
)
})?;
let _ = tokio::fs::remove_file(parked).await;
Ok(())
}
#[cfg(all(test, feature = "canopy-restore"))]
mod in_place_tests {
use super::*;
use crate::actions::canopy::restore::inplace::Interlock;
fn plan(dest: &str, data_dir: &str) -> resolve::RestorePlan {
resolve::RestorePlan {
source: PathBuf::from("/capture"),
dest: PathBuf::from(dest),
data_major: "16".into(),
whole_install: dest != data_dir,
target: resolve::ResolvedCluster {
version: "16".into(),
cluster: "main".into(),
data_dir: PathBuf::from(data_dir),
},
}
}
#[test]
fn the_interlock_keeps_its_own_files_out_of_the_sync() {
let skips = interlock_skips(&plan("/pg/16/main", "/pg/16/main")).unwrap();
assert!(skips.contains(&PathBuf::from("PG_VERSION")));
assert!(skips.contains(&PathBuf::from("PG_VERSION.bestool-restoring")));
assert!(skips.contains(&PathBuf::from(Interlock::marker_name())));
}
#[test]
fn the_skips_are_named_from_the_root_of_the_tree_being_walked() {
let skips = interlock_skips(&plan("/pg/16", "/pg/16/data")).unwrap();
assert!(skips.contains(&PathBuf::from("data/PG_VERSION")));
assert!(skips.contains(&PathBuf::from("data/PG_VERSION.bestool-restoring")));
assert!(skips.contains(&PathBuf::from("data").join(Interlock::marker_name())));
}
#[test]
fn a_data_directory_outside_the_restored_tree_is_refused() {
assert!(interlock_skips(&plan("/pg/16", "/elsewhere/data")).is_err());
}
#[tokio::test]
async fn a_resumed_restore_keeps_the_copy_already_parked() {
let tmp = tempfile::tempdir().unwrap();
let version_file = tmp.path().join("PG_VERSION");
let parked = tmp.path().join("PG_VERSION.bestool-restoring");
std::fs::write(&parked, "16").unwrap();
std::fs::write(&version_file, "partial").unwrap();
park_version_file(&version_file, &parked).await.unwrap();
assert_eq!(std::fs::read_to_string(&parked).unwrap(), "16");
assert!(
!version_file.exists(),
"the cluster must stay unstartable across the resume"
);
}
#[tokio::test]
async fn parking_takes_the_version_file_out_of_the_way() {
let tmp = tempfile::tempdir().unwrap();
let version_file = tmp.path().join("PG_VERSION");
let parked = tmp.path().join("PG_VERSION.bestool-restoring");
std::fs::write(&version_file, "16").unwrap();
park_version_file(&version_file, &parked).await.unwrap();
assert!(!version_file.exists());
assert_eq!(std::fs::read_to_string(&parked).unwrap(), "16");
}
#[tokio::test]
async fn parking_an_absent_version_file_is_not_an_error() {
let tmp = tempfile::tempdir().unwrap();
park_version_file(
&tmp.path().join("PG_VERSION"),
&tmp.path().join("PG_VERSION.bestool-restoring"),
)
.await
.unwrap();
}
#[tokio::test]
async fn writing_the_version_file_back_clears_the_parked_copy() {
let tmp = tempfile::tempdir().unwrap();
let capture = tmp.path().join("capture");
let live = tmp.path().join("live");
std::fs::create_dir_all(&capture).unwrap();
std::fs::create_dir_all(&live).unwrap();
std::fs::write(capture.join("PG_VERSION"), "16").unwrap();
let version_file = live.join("PG_VERSION");
let parked = live.join("PG_VERSION.bestool-restoring");
std::fs::write(&parked, "16").unwrap();
sync_version_file(&capture, &version_file, &parked).await.unwrap();
assert_eq!(std::fs::read_to_string(&version_file).unwrap(), "16");
assert!(!parked.exists());
}
}