use std::{
path::{Path, PathBuf},
process::Stdio,
};
use miette::{Context as _, IntoDiagnostic as _, Result, bail, miette};
use tracing::info;
use super::{
super::{hold::HeldCapture, method::PostgresqlConfig},
resolve::ResolvedCluster,
sys,
};
fn destination(root: &Path, resolved: &ResolvedCluster) -> PathBuf {
root.join(&resolved.version).join(&resolved.cluster)
}
fn basebackup_args(dest: &Path) -> Vec<String> {
vec![
"-D".to_owned(),
dest.to_string_lossy().into_owned(),
"--wal-method=stream".to_owned(),
"--checkpoint=fast".to_owned(),
"--no-password".to_owned(),
]
}
pub async fn prepare(
resolved: &ResolvedCluster,
backup_type: &str,
config: &PostgresqlConfig,
need: Option<u64>,
) -> Result<(PathBuf, PathBuf)> {
let root = super::space::choose_staging_root(backup_type, config.staging_dir.as_deref(), need)?;
let dest = destination(&root, resolved);
if root.exists() {
remove_staging(&root).await?;
}
if let Some(parent) = dest.parent() {
tokio::fs::create_dir_all(parent)
.await
.into_diagnostic()
.wrap_err_with(|| format!("creating {}", parent.display()))?;
}
make_parents_traversable(&root).await?;
#[cfg(unix)]
make_writable_by_postgres(&root).await?;
ensure_dest_clear(&dest).await?;
info!(dest = %dest.display(), "streaming pg_basebackup");
let mut cmd = super::pg_command(&super::postgres_bin("pg_basebackup", &resolved.data_dir));
cmd.args(basebackup_args(&dest));
super::apply_connection(&mut cmd, config);
cmd.stdin(Stdio::null());
let output = cmd
.output()
.await
.into_diagnostic()
.wrap_err("spawning pg_basebackup")?;
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() {
bail!("{}", failure_message(output.status, &stderr));
}
if !stderr.trim().is_empty() {
info!(stderr = %stderr.trim(), "pg_basebackup");
}
#[cfg(unix)]
make_readable_by_kopia(&root).await;
Ok((dest, root))
}
async fn ensure_dest_clear(dest: &Path) -> Result<()> {
if tokio::fs::try_exists(dest).await.unwrap_or(false) {
bail!(
"another process is already writing a base backup to {}; \
refusing to start a second pg_basebackup into the same directory",
dest.display()
);
}
Ok(())
}
fn failure_message(status: impl std::fmt::Display, stderr: &str) -> String {
let stderr = stderr.trim();
if stderr.is_empty() {
format!("pg_basebackup failed ({status})")
} else {
format!("pg_basebackup failed ({status}): {stderr}")
}
}
pub async fn teardown(root: PathBuf) -> Result<()> {
remove_staging(&root).await
}
pub async fn hold(root: PathBuf, id: &str, source: &Path) -> Result<(PathBuf, HeldCapture)> {
let rel = source
.strip_prefix(&root)
.map_err(|_| {
miette!(
"{} is not under the capture's staging root {}",
source.display(),
root.display()
)
})?
.to_path_buf();
let held_root = super::super::hold::hold_source_dir(id);
if let Some(parent) = held_root.parent() {
tokio::fs::create_dir_all(parent)
.await
.into_diagnostic()
.wrap_err_with(|| format!("creating {}", parent.display()))?;
}
tokio::fs::rename(&root, &held_root)
.await
.into_diagnostic()
.wrap_err_with(|| {
format!(
"moving the base backup from {} to {}",
root.display(),
held_root.display()
)
})?;
info!(hold = %id, root = %held_root.display(), "held base backup");
Ok((held_root.join(rel), HeldCapture::BaseBackup { root: held_root }))
}
async fn remove_staging(root: &Path) -> Result<()> {
#[cfg(unix)]
{
let _ = tokio::process::Command::new("chown")
.args(["-R", "root:root"])
.arg(root)
.stdin(Stdio::null())
.status()
.await;
}
tokio::fs::remove_dir_all(root)
.await
.into_diagnostic()
.wrap_err_with(|| format!("removing base backup at {}", root.display()))
}
fn parents_to_widen(root: &Path) -> Vec<&Path> {
[root.parent(), root.parent().and_then(Path::parent)]
.into_iter()
.flatten()
.collect()
}
async fn make_parents_traversable(root: &Path) -> Result<()> {
for dir in parents_to_widen(root) {
sys::make_traversable(dir).await?;
}
Ok(())
}
#[cfg(unix)]
async fn make_writable_by_postgres(root: &Path) -> Result<()> {
let status = tokio::process::Command::new("chown")
.args(["-R", "postgres:postgres"])
.arg(root)
.stdin(Stdio::null())
.status()
.await
.into_diagnostic()
.wrap_err("handing the base backup staging to the postgres user")?;
if !status.success() {
bail!("chown of base backup staging to postgres failed ({status})");
}
Ok(())
}
#[cfg(unix)]
async fn make_readable_by_kopia(root: &Path) {
let kopia_exists = tokio::process::Command::new("id")
.args(["-u", "kopia"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
.map(|s| s.success())
.unwrap_or(false);
if !kopia_exists {
return;
}
let status = tokio::process::Command::new("chown")
.args(["-R", "kopia:kopia"])
.arg(root)
.stdin(Stdio::null())
.status()
.await;
if !matches!(status, Ok(s) if s.success()) {
tracing::warn!("could not chown base backup to the kopia user; kopia may fail to read it");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn destination_nests_version_and_cluster_under_root() {
let resolved = ResolvedCluster {
data_dir: "/var/lib/postgresql/16/main".into(),
version: "16".into(),
cluster: "main".into(),
};
assert_eq!(
destination(Path::new("/staging/tamanu-postgres"), &resolved),
Path::new("/staging/tamanu-postgres/16/main")
);
}
#[tokio::test]
async fn ensure_dest_clear_rejects_a_reappeared_destination() {
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("12").join("main");
assert!(ensure_dest_clear(&dest).await.is_ok());
tokio::fs::create_dir_all(&dest).await.unwrap();
assert!(ensure_dest_clear(&dest).await.is_err());
}
#[test]
fn parents_to_widen_covers_the_dirs_we_create() {
let root = Path::new("/var/lib/bestool/backup-source/tamanu-postgres");
assert_eq!(
parents_to_widen(root),
vec![
Path::new("/var/lib/bestool/backup-source"),
Path::new("/var/lib/bestool"),
]
);
}
#[test]
fn parents_to_widen_stops_at_the_filesystem_root() {
assert_eq!(parents_to_widen(Path::new("/")), Vec::<&Path>::new());
assert_eq!(parents_to_widen(Path::new("/staging")), vec![Path::new("/")]);
}
#[cfg(unix)]
#[tokio::test]
async fn make_parents_traversable_widens_group_only_parents() {
use std::os::unix::fs::PermissionsExt as _;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("bestool/backup-source/tamanu-postgres");
std::fs::create_dir_all(&root).unwrap();
for dir in parents_to_widen(&root) {
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o770)).unwrap();
}
make_parents_traversable(&root).await.unwrap();
for dir in parents_to_widen(&root) {
let mode = std::fs::metadata(dir).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o755, "{} should be traversable", dir.display());
}
}
#[test]
fn failure_message_carries_stderr() {
assert_eq!(
failure_message(
"exit code: 1",
"pg_basebackup: error: connection to server at \"localhost\" failed: FATAL: role \"SYSTEM\" does not exist\n",
),
"pg_basebackup failed (exit code: 1): pg_basebackup: error: connection to server at \"localhost\" failed: FATAL: role \"SYSTEM\" does not exist"
);
}
#[test]
fn failure_message_without_stderr_is_just_the_status() {
assert_eq!(
failure_message("exit code: 1", " \n"),
"pg_basebackup failed (exit code: 1)"
);
}
#[test]
fn basebackup_args_stream_wal_and_fast_checkpoint() {
let args = basebackup_args(Path::new("/staging/16/main"));
assert_eq!(
args,
vec![
"-D",
"/staging/16/main",
"--wal-method=stream",
"--checkpoint=fast",
"--no-password",
]
);
}
}