use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use tokio::io::AsyncReadExt;
use crate::commands::job::rclone_log::{self, RcloneLogTailer};
use crate::commands::job::rclone_transfer;
pub(crate) struct RcloneCopyPlan {
pub source: String,
pub destination: String,
pub log_path: PathBuf,
pub transfers: usize,
pub checkers: usize,
pub tpslimit: Option<usize>,
}
#[derive(Debug, Default, Clone)]
pub(crate) struct RcloneCopySummary {
pub transferred: u64,
pub errors: u64,
pub bytes: u64,
pub log_path: PathBuf,
}
pub(crate) struct RcloneDeletePlan {
pub source: String,
pub log_path: PathBuf,
pub checkers: usize,
}
#[derive(Debug, Default, Clone)]
pub(crate) struct RcloneDeleteSummary {
pub deleted: u64,
pub errors: u64,
pub log_path: PathBuf,
}
const LOG_POLL_INTERVAL: Duration = Duration::from_secs(10);
pub(crate) async fn check_rclone_available() -> Result<(), String> {
tokio::process::Command::new("rclone")
.arg("version")
.output()
.await
.map_err(|_| {
"'rclone' was not found on PATH -- required for 'pigeon job run rclone' to copy/delete data"
.to_string()
})?;
Ok(())
}
fn emit_delta_metrics_delete(delta: &rclone_log::TailDelta) {
rclone_transfer::log_collapsed_repeats("delete", delta);
if delta.is_empty() {
return;
}
if delta.deletes > 0 {
crate::observability::metrics::record_phase_count(
"rclone-delete",
"delete",
"deleted",
delta.deletes,
None,
);
}
if delta.errors > 0 {
crate::observability::metrics::record_phase_count(
"rclone-delete",
"delete",
"failed",
delta.errors,
None,
);
}
}
pub(crate) async fn run_copy_job(
source: &str,
destination: &str,
log_path: &Path,
transfers: usize,
checkers: usize,
tpslimit: Option<usize>,
) -> Result<RcloneCopySummary, String> {
crate::observability::metrics::set_macro_phase("rclone-copy", true);
let log_summary = rclone_transfer::run_rclone_copy(
source,
destination,
None,
log_path,
transfers,
checkers,
tpslimit,
"rclone-copy",
"transfer",
true,
None,
None,
)
.await?;
Ok(RcloneCopySummary {
transferred: log_summary.transferred,
errors: log_summary.errors,
bytes: log_summary.bytes,
log_path: log_path.to_path_buf(),
})
}
pub(crate) async fn run_delete_job(
source: &str,
log_path: &Path,
checkers: usize,
) -> Result<RcloneDeleteSummary, String> {
tracing::info!(source, "rclone: purge starting");
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|err| format!("failed to create {}: {err}", parent.display()))?;
}
let rclone_args: Vec<String> = [
"--checkers",
&checkers.to_string(),
"--fast-list",
"--retries",
"5",
"--low-level-retries",
"20",
"--stats",
"30s",
"--use-json-log",
"--log-level",
"INFO",
"--log-file",
]
.iter()
.map(|s| s.to_string())
.collect();
let mut child = tokio::process::Command::new("rclone")
.arg("purge")
.arg(source)
.args(rclone_args)
.arg(log_path)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|err| format!("failed to run rclone: {err}"))?;
let mut stderr_pipe = child.stderr.take().expect("stderr was configured as piped");
let stderr_task = tokio::spawn(async move {
let mut buf = String::new();
let _ = stderr_pipe.read_to_string(&mut buf).await;
buf
});
crate::observability::metrics::set_macro_phase("rclone-delete", true);
let mut tailer = RcloneLogTailer::new(log_path, "delete");
let mut wait_handle = tokio::spawn(async move { child.wait().await });
let mut poll_interval = tokio::time::interval(LOG_POLL_INTERVAL);
poll_interval.tick().await;
let exit_status = loop {
tokio::select! {
join_result = &mut wait_handle => {
let wait_result = join_result
.map_err(|err| format!("rclone process join failed: {err}"))?;
break wait_result;
}
_ = poll_interval.tick() => {
emit_delta_metrics_delete(&tailer.poll());
}
}
};
let exit_status = exit_status.map_err(|err| format!("failed to run rclone: {err}"))?;
emit_delta_metrics_delete(&tailer.poll());
let log_summary = tailer.summary();
let exit_code = exit_status.code();
let stderr = stderr_task.await.unwrap_or_default();
tracing::info!(
deleted = log_summary.deletes,
errors = log_summary.errors,
exit_code,
"rclone: purge complete"
);
let summary = RcloneDeleteSummary {
deleted: log_summary.deletes,
errors: log_summary.errors,
log_path: log_path.to_path_buf(),
};
if !exit_status.success() {
return Err(format!(
"rclone purge exited with status {} ({} object(s) deleted, {} error(s)); see {} for details{}",
exit_code
.map(|code| code.to_string())
.unwrap_or_else(|| "unknown (terminated by signal)".to_string()),
summary.deleted,
summary.errors,
log_path.display(),
if stderr.trim().is_empty() {
String::new()
} else {
format!(" -- rclone stderr: {}", stderr.trim())
},
));
}
Ok(summary)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn copies_a_file_between_two_local_directories() {
if check_rclone_available().await.is_err() {
eprintln!("skipping: rclone not found on PATH");
return;
}
let source_dir = tempfile::tempdir().unwrap();
let dest_dir = tempfile::tempdir().unwrap();
let log_dir = tempfile::tempdir().unwrap();
std::fs::write(source_dir.path().join("hello.txt"), b"hello world").unwrap();
let log_path = log_dir.path().join("rclone.jsonl");
let summary = run_copy_job(
source_dir.path().to_str().unwrap(),
dest_dir.path().to_str().unwrap(),
&log_path,
2,
4,
None,
)
.await
.unwrap();
assert_eq!(summary.transferred, 1);
assert_eq!(summary.errors, 0);
assert!(dest_dir.path().join("hello.txt").exists());
}
#[tokio::test]
async fn copies_a_file_with_a_tpslimit_set() {
if check_rclone_available().await.is_err() {
eprintln!("skipping: rclone not found on PATH");
return;
}
let source_dir = tempfile::tempdir().unwrap();
let dest_dir = tempfile::tempdir().unwrap();
let log_dir = tempfile::tempdir().unwrap();
std::fs::write(source_dir.path().join("hello.txt"), b"hello world").unwrap();
let log_path = log_dir.path().join("rclone.jsonl");
let summary = run_copy_job(
source_dir.path().to_str().unwrap(),
dest_dir.path().to_str().unwrap(),
&log_path,
8,
16,
Some(10),
)
.await
.unwrap();
assert_eq!(summary.transferred, 1);
assert_eq!(summary.errors, 0);
assert!(dest_dir.path().join("hello.txt").exists());
}
#[tokio::test]
async fn purges_everything_under_a_local_directory() {
if check_rclone_available().await.is_err() {
eprintln!("skipping: rclone not found on PATH");
return;
}
let source_dir = tempfile::tempdir().unwrap();
let log_dir = tempfile::tempdir().unwrap();
std::fs::write(source_dir.path().join("a.txt"), b"a").unwrap();
std::fs::create_dir(source_dir.path().join("sub")).unwrap();
std::fs::write(source_dir.path().join("sub/b.txt"), b"b").unwrap();
let log_path = log_dir.path().join("rclone-purge.jsonl");
let summary = run_delete_job(source_dir.path().to_str().unwrap(), &log_path, 4)
.await
.unwrap();
assert_eq!(summary.errors, 0);
assert!(summary.deleted >= 1);
assert!(!source_dir.path().join("a.txt").exists());
assert!(!source_dir.path().join("sub").exists());
}
}