use std::{
path::{Path, PathBuf},
process::Command,
};
use tempfile::TempDir;
use super::{BALLAST_BYTES, Backend, clear_records, lifecycle};
use crate::actions::canopy::backup::{
hold::{HeldCapture, HoldRecord},
postgresql::lvm,
};
const SCRATCH_BASE: &str = "/srv";
const POOL_MIB: usize = BALLAST_BYTES / (1024 * 1024) * 32;
const VOLUME_MIB: usize = POOL_MIB * 3 / 2;
const IMAGE_MIB: usize = POOL_MIB * 2;
enum Storage {
Btrfs { loopdev: String, mount: PathBuf },
ThinLvm {
loopdev: String,
vg: String,
mount: PathBuf,
},
BaseBackup,
}
struct Harness {
backup_type: String,
backups_dir: TempDir,
data_dir: PathBuf,
version: String,
cluster: String,
port: u16,
scratch: PathBuf,
storage: Storage,
}
impl Harness {
async fn btrfs() -> Self {
let scratch = scratch("btrfs");
let image = scratch.join("fs.img");
run("truncate", &["-s", "6G", str(&image)]);
run("mkfs.btrfs", &["-q", str(&image)]);
let loopdev = settle(capture("losetup", &["--find", "--show", str(&image)]));
let toplevel = scratch.join("toplevel");
std::fs::create_dir_all(&toplevel).unwrap();
run("mount", &["-o", "subvolid=5", "--", &loopdev, str(&toplevel)]);
run(
"btrfs",
&["subvolume", "create", str(&toplevel.join("pgdata"))],
);
run("umount", &[str(&toplevel)]);
let mount = scratch.join("pgdata");
std::fs::create_dir_all(&mount).unwrap();
run("mount", &["-o", "subvol=pgdata", "--", &loopdev, str(&mount)]);
run("btrfs", &["quota", "enable", str(&mount)]);
Self::with_cluster(
"hold-e2e-btrfs",
"holdbtrfs",
&mount,
scratch,
Storage::Btrfs { loopdev, mount: mount.clone() },
)
.await
}
async fn thin_lvm() -> Self {
let scratch = scratch("lvm");
let image = scratch.join("pv.img");
run("truncate", &["-s", &format!("{IMAGE_MIB}m"), str(&image)]);
let loopdev = settle(capture("losetup", &["--find", "--show", str(&image)]));
let vg = "bestoolholde2e".to_owned();
run("vgcreate", &[&vg, &loopdev]);
run(
"lvcreate",
&[
"--type",
"thin-pool",
"-L",
&format!("{POOL_MIB}m"),
"-n",
"pgpool",
&vg,
],
);
run(
"lvcreate",
&[
"--thin",
"-V",
&format!("{VOLUME_MIB}m"),
"-n",
"pgdata",
&format!("{vg}/pgpool"),
],
);
let device = format!("/dev/{vg}/pgdata");
run(
"mkfs.ext4",
&[
"-q",
"-E",
"lazy_itable_init=0,lazy_journal_init=0,nodiscard",
&device,
],
);
let mount = scratch.join("pgdata");
std::fs::create_dir_all(&mount).unwrap();
run("mount", &["--", &device, str(&mount)]);
Self::with_cluster(
"hold-e2e-lvm",
"holdlvm",
&mount,
scratch,
Storage::ThinLvm {
loopdev,
vg,
mount: mount.clone(),
},
)
.await
}
async fn base_backup() -> Self {
let scratch = scratch("basebackup");
Self::with_cluster(
"hold-e2e-basebackup",
"holdbase",
&scratch,
scratch.clone(),
Storage::BaseBackup,
)
.await
}
async fn with_cluster(
backup_type: &str,
cluster: &str,
parent: &Path,
scratch: PathBuf,
storage: Storage,
) -> Self {
clear_records(backup_type).await;
let version = installed_major();
let data_dir = parent.join(&version).join(cluster);
std::fs::create_dir_all(data_dir.parent().unwrap()).unwrap();
run(
"pg_createcluster",
&["--datadir", str(&data_dir), &version, cluster],
);
run("systemctl", &["start", &unit(&version, cluster)]);
let port = cluster_port(&version, cluster);
let backups_dir = TempDir::new().expect("a directory for the backup def");
std::fs::write(
backups_dir.path().join("def.toml"),
format!(
"type = \"{backup_type}\"\n\
[postgresql]\n\
cluster = \"{cluster}\"\n\
data_dir = \"{}\"\n\
port = {port}\n",
data_dir.display(),
),
)
.expect("writing the backup def");
Self {
backup_type: backup_type.to_owned(),
backups_dir,
data_dir,
version,
cluster: cluster.to_owned(),
port,
scratch,
storage,
}
}
}
impl Backend for Harness {
fn backup_type(&self) -> &str {
&self.backup_type
}
fn backups_dir(&self) -> &Path {
self.backups_dir.path()
}
fn data_dir(&self) -> &Path {
&self.data_dir
}
fn expected_backend(&self) -> &'static str {
match self.storage {
Storage::Btrfs { .. } => "btrfs",
Storage::ThinLvm { .. } => "lvm",
Storage::BaseBackup => "basebackup",
}
}
fn exposes_separately(&self) -> bool {
!matches!(self.storage, Storage::BaseBackup)
}
async fn cluster_is_up(&self) -> bool {
let bin = crate::find_postgres::find_postgres_bin("pg_isready")
.expect("pg_isready is installed with the postgres client");
Command::new(bin)
.args(["-h", "/var/run/postgresql", "-p", &self.port.to_string()])
.status()
.map(|status| status.success())
.unwrap_or(false)
}
async fn capture_present(&self, record: &HoldRecord) -> bool {
match (&self.storage, &record.capture) {
(Storage::Btrfs { loopdev, .. }, HeldCapture::Btrfs { snapshot_path, .. }) => {
let Some(name) = snapshot_path.file_name().map(std::path::Path::new) else {
return false;
};
let probe = self.scratch.join("toplevel-probe");
std::fs::create_dir_all(&probe).expect("a mountpoint to probe from");
run("mount", &["-o", "subvolid=5", "--", loopdev, str(&probe)]);
let present = probe.join(name).is_dir();
try_run("umount", &[str(&probe)]);
present
}
(Storage::ThinLvm { .. }, HeldCapture::Lvm { vg, lv, .. }) => {
lvm::held_present(vg, lv).await
}
(Storage::BaseBackup, HeldCapture::BaseBackup { root }) => root.exists(),
(_, capture) => panic!(
"the {} backend held a {} capture",
self.expected_backend(),
capture.backend()
),
}
}
fn needs_ballast(&self) -> bool {
!matches!(self.storage, Storage::BaseBackup)
}
async fn capture_exclusive_bytes(&self, record: &HoldRecord) -> Option<u64> {
let (Storage::Btrfs { mount, .. }, HeldCapture::Btrfs { snapshot_path, .. }) =
(&self.storage, &record.capture)
else {
return None;
};
try_run("sync", &["-f", str(mount)]);
let name = snapshot_path.file_name()?.to_string_lossy();
let report = capture_ok("btrfs", &["qgroup", "show", "-re", "--raw", str(mount)])?;
qgroup_exclusive(&report, &name)
}
async fn store_in_use(&self) -> Option<f64> {
match &self.storage {
Storage::Btrfs { .. } => None,
Storage::ThinLvm { vg, .. } => {
capture_ok("lvs", &["--noheadings", "-o", "data_percent", &format!("{vg}/pgpool")])?
.trim()
.parse()
.ok()
}
Storage::BaseBackup => None,
}
}
async fn quiesce_store(&self) {
try_run("systemctl", &["stop", &unit(&self.version, &self.cluster)]);
try_run("sync", &["-f", str(&self.data_dir)]);
}
async fn settle_store(&self) {
if let Storage::Btrfs { mount, .. } = &self.storage {
try_run("btrfs", &["subvolume", "sync", str(mount)]);
}
try_run("sync", &["-f", str(&self.data_dir)]);
}
async fn store_diagnostics(&self) -> String {
match &self.storage {
Storage::Btrfs { mount, .. } => format!(
"btrfs filesystem df:\n{}\nbtrfs filesystem usage:\n{}\nsubvolumes:\n{}",
capture_ok("btrfs", &["filesystem", "df", str(mount)]).unwrap_or_default(),
capture_ok("btrfs", &["filesystem", "usage", str(mount)]).unwrap_or_default(),
capture_ok("btrfs", &["subvolume", "list", str(mount)]).unwrap_or_default(),
),
Storage::ThinLvm { vg, .. } => format!(
"lvs:\n{}",
capture_ok("lvs", &["-o", "lv_name,lv_size,data_percent", vg]).unwrap_or_default(),
),
Storage::BaseBackup => String::new(),
}
}
fn released_margin(&self) -> f64 {
match self.storage {
Storage::Btrfs { .. } => BALLAST_BYTES as f64 / 2.0,
Storage::ThinLvm { .. } => 100.0 / POOL_MIB as f64 * BALLAST_BYTES as f64
/ (1024.0 * 1024.0)
/ 2.0,
Storage::BaseBackup => 0.0,
}
}
}
impl Drop for Harness {
fn drop(&mut self) {
try_run("pg_dropcluster", &["--stop", &self.version, &self.cluster]);
match &self.storage {
Storage::Btrfs { loopdev, mount } => {
umount_all_from(|source| source == loopdev);
try_run("umount", &[str(mount)]);
try_run("losetup", &["-d", loopdev]);
}
Storage::ThinLvm { loopdev, vg, mount } => {
let prefix = format!("/dev/mapper/{vg}-");
umount_all_from(|source| source.starts_with(&prefix));
try_run("umount", &[str(mount)]);
try_run("vgremove", &["-f", vg]);
try_run("pvremove", &["-f", loopdev]);
try_run("losetup", &["-d", loopdev]);
}
Storage::BaseBackup => {}
}
let _ = std::fs::remove_dir_all(&self.scratch);
}
}
#[tokio::test]
#[ignore = "needs root, btrfs-progs and postgres; run in the `btrfs hold / e2e` CI job"]
async fn a_btrfs_hold_is_taken_used_and_released() {
lifecycle(&Harness::btrfs().await).await;
}
#[tokio::test]
#[ignore = "needs root, lvm2 and postgres; run in the `thin-lvm hold / e2e` CI job"]
async fn a_thin_lvm_hold_is_taken_used_and_released() {
lifecycle(&Harness::thin_lvm().await).await;
}
#[tokio::test]
#[ignore = "needs root and postgres; run in the `base backup hold / e2e` CI job"]
async fn a_base_backup_hold_is_taken_used_and_released() {
lifecycle(&Harness::base_backup().await).await;
}
fn installed_major() -> String {
std::fs::read_dir("/usr/lib/postgresql")
.expect("a postgres server is installed under /usr/lib/postgresql")
.flatten()
.filter_map(|entry| entry.file_name().into_string().ok())
.filter_map(|name| name.parse::<u32>().ok())
.max()
.expect("a versioned postgres install under /usr/lib/postgresql")
.to_string()
}
fn unit(version: &str, cluster: &str) -> String {
format!("postgresql@{version}-{cluster}")
}
fn cluster_port(version: &str, cluster: &str) -> u16 {
let shown = capture("pg_conftool", &["-s", version, cluster, "show", "port"]);
let value = shown.rsplit('=').next().unwrap_or(&shown).trim();
value
.parse()
.unwrap_or_else(|err| panic!("parsing the cluster port from {shown:?}: {err}"))
}
fn scratch(backend: &str) -> PathBuf {
let dir = PathBuf::from(SCRATCH_BASE).join(format!("bestool-hold-e2e-{backend}"));
let under = format!("{}/", dir.display());
umount_all_under(|target| target == dir.to_string_lossy() || target.starts_with(&under));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("creating the scratch directory");
run("chmod", &["755", str(&dir)]);
dir
}
fn umount_all_from(matches: impl Fn(&str) -> bool) {
umount_all(|source, _| matches(source));
}
fn umount_all_under(matches: impl Fn(&str) -> bool) {
umount_all(|_, target| matches(target));
}
fn umount_all(matches: impl Fn(&str, &str) -> bool) {
let Ok(mounts) = std::fs::read_to_string("/proc/mounts") else {
return;
};
let mut targets: Vec<&str> = mounts
.lines()
.filter_map(|line| {
let mut fields = line.split_whitespace();
let source = fields.next()?;
let target = fields.next()?;
matches(source, target).then_some(target)
})
.collect();
targets.sort_by_key(|target| std::cmp::Reverse(target.len()));
for target in targets {
try_run("umount", &["-l", target]);
}
}
fn settle(device: String) -> String {
try_run("udevadm", &["settle"]);
device
}
fn qgroup_exclusive(report: &str, subvolume: &str) -> Option<u64> {
report.lines().find_map(|line| {
let fields: Vec<&str> = line.split_whitespace().collect();
let [qgroup, _referenced, exclusive, .., path] = fields.as_slice() else {
return None;
};
(*path == subvolume && qgroup.starts_with("0/"))
.then(|| exclusive.parse().ok())
.flatten()
})
}
fn str(path: &Path) -> &str {
path.to_str().expect("an ASCII path")
}
fn run(program: &str, args: &[&str]) {
let output = Command::new(program)
.args(args)
.output()
.unwrap_or_else(|err| panic!("spawning {program}: {err}"));
assert!(
output.status.success(),
"{program} {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr).trim(),
);
}
fn try_run(program: &str, args: &[&str]) {
let _ = Command::new(program).args(args).output();
}
fn capture(program: &str, args: &[&str]) -> String {
capture_ok(program, args)
.unwrap_or_else(|| panic!("{program} {} produced nothing usable", args.join(" ")))
}
fn capture_ok(program: &str, args: &[&str]) -> Option<String> {
let output = Command::new(program).args(args).output().ok()?;
output
.status
.success()
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exclusive_bytes_are_read_off_the_quota_report() {
let report = "Qgroupid Referenced Exclusive Max referenced Max exclusive Path \n\
-------- ---------- --------- -------------- ------------- ---- \n\
0/5 16384 16384 none none <toplevel>\n\
0/256 161677312 135593984 none none pgdata\n\
0/257 93257728 67174400 none none bestool-held-x";
assert_eq!(qgroup_exclusive(report, "bestool-held-x"), Some(67174400));
assert_eq!(qgroup_exclusive(report, "pgdata"), Some(135593984));
}
#[test]
fn a_missing_qgroup_is_not_an_empty_capture() {
let report = "0/257 93257728 67174400 none none bestool-held-x";
assert_eq!(qgroup_exclusive(report, "bestool-held-gone"), None);
assert_eq!(qgroup_exclusive("", "bestool-held-x"), None);
assert_eq!(qgroup_exclusive("Qgroupid Referenced Exclusive", "Exclusive"), None);
}
}