pub(crate) mod command;
pub(crate) mod config;
pub(crate) mod log;
use std::path::Path;
use std::process::Stdio;
use std::time::Duration;
use tokio::io::AsyncReadExt;
pub(crate) use log::{RcloneLogSummary, RcloneLogTailer, TailDelta};
const LOG_POLL_INTERVAL: Duration = Duration::from_secs(10);
#[derive(Default, Clone)]
pub(crate) struct StreamHooks {
pub on_copied: Option<tokio::sync::mpsc::UnboundedSender<String>>,
pub pid_tx: Option<tokio::sync::mpsc::Sender<u32>>,
}
pub(crate) enum Observation {
Streamed(StreamHooks),
OneShot,
}
pub(crate) struct Labels {
pub job: &'static str,
pub step: &'static str,
pub success_outcome: &'static str,
pub upload_metrics: bool,
}
pub(crate) struct Outcome {
pub exit_code: Option<i32>,
pub success: bool,
pub stdout: Vec<u8>,
pub stderr: String,
pub log: RcloneLogSummary,
}
pub(crate) trait RcloneCommand {
type Output;
fn subcommand(&self) -> &'static str;
fn args(&self) -> Vec<String>;
fn log_path(&self) -> Option<&Path>;
fn observe(&self) -> Observation;
fn labels(&self) -> Labels;
fn finish(&self, outcome: Outcome) -> Result<Self::Output, String>;
fn retry(&self) -> Option<(usize, Duration)> {
None
}
async fn run(&self) -> Result<Self::Output, String>
where
Self: Sized,
{
match self.retry() {
Some((attempts, backoff)) => {
crate::core::retry::retry_with_backoff(attempts, backoff, || execute(self)).await
}
None => execute(self).await,
}
}
}
pub(crate) async fn check_available() -> Result<(), String> {
tokio::process::Command::new("rclone")
.arg("version")
.output()
.await
.map_err(|_| "'rclone' was not found on PATH".to_string())?;
Ok(())
}
pub(crate) fn is_endpoint_unreachable_error(err: &str) -> bool {
const ENDPOINT_UNREACHABLE_SIGNATURES: &[&str] = &[
"no such host",
"dial tcp",
"connection refused",
"connection reset by peer",
"i/o timeout",
"no route to host",
"network is unreachable",
"TLS handshake timeout",
];
ENDPOINT_UNREACHABLE_SIGNATURES
.iter()
.any(|signature| err.contains(signature))
}
async fn execute<C: RcloneCommand + ?Sized>(cmd: &C) -> Result<C::Output, String> {
let mut argv = cmd.args();
let streamed = matches!(cmd.observe(), Observation::Streamed(_));
argv.extend(["--retries", "5", "--low-level-retries", "20"].map(String::from));
if let Some(log_path) = cmd.log_path() {
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|err| format!("failed to create {}: {err}", parent.display()))?;
}
if streamed {
argv.extend(["--stats", "30s"].map(String::from));
}
argv.extend(["--use-json-log", "--log-level", "INFO", "--log-file"].map(String::from));
argv.push(log_path.display().to_string());
}
argv.push("--config".to_string());
argv.push(config::rclone_config_path()?.display().to_string());
let labels = cmd.labels();
match cmd.observe() {
Observation::Streamed(hooks) => execute_streamed(cmd, argv, &labels, hooks).await,
Observation::OneShot => execute_one_shot(cmd, argv, &labels).await,
}
}
async fn execute_streamed<C: RcloneCommand + ?Sized>(
cmd: &C,
argv: Vec<String>,
labels: &Labels,
hooks: StreamHooks,
) -> Result<C::Output, String> {
let subcommand = cmd.subcommand();
tracing::info!(step = labels.step, "rclone: {subcommand} starting");
let mut child = tokio::process::Command::new("rclone")
.arg(subcommand)
.args(&argv)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|err| format!("failed to run rclone: {err}"))?;
if let (Some(tx), Some(pid)) = (&hooks.pid_tx, child.id()) {
let _ = tx.send(pid).await;
}
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
});
let log_path = cmd
.log_path()
.expect("a Streamed command always has a log_path")
.to_path_buf();
let mut tailer = RcloneLogTailer::new(&log_path, labels.step);
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() => {
let delta = tailer.poll();
emit_delta_metrics(labels, &delta);
forward_copied(&hooks.on_copied, delta.copied_objects);
}
}
};
let exit_status = exit_status.map_err(|err| format!("failed to run rclone: {err}"))?;
let final_delta = tailer.poll();
emit_delta_metrics(labels, &final_delta);
forward_copied(&hooks.on_copied, final_delta.copied_objects);
let log_summary = tailer.summary();
let exit_code = exit_status.code();
let stderr = stderr_task.await.unwrap_or_default();
tracing::info!(
transferred = log_summary.transferred,
errors = log_summary.errors,
bytes = log_summary.bytes,
deletes = log_summary.deletes,
exit_code,
step = labels.step,
"rclone: {subcommand} complete"
);
cmd.finish(Outcome {
exit_code,
success: exit_status.success(),
stdout: Vec::new(),
stderr,
log: log_summary,
})
}
async fn execute_one_shot<C: RcloneCommand + ?Sized>(
cmd: &C,
argv: Vec<String>,
labels: &Labels,
) -> Result<C::Output, String> {
let output = tokio::process::Command::new("rclone")
.arg(cmd.subcommand())
.args(&argv)
.output()
.await
.map_err(|err| format!("failed to run rclone: {err}"))?;
let exit_code = output.status.code();
let success = output.status.success();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
crate::observability::metrics::record_phase_count(
labels.job,
labels.step,
if success {
labels.success_outcome
} else {
"failed"
},
1,
None,
);
cmd.finish(Outcome {
exit_code,
success,
stdout: output.stdout,
stderr,
log: RcloneLogSummary::default(),
})
}
fn emit_delta_metrics(labels: &Labels, delta: &TailDelta) {
log_collapsed_repeats(labels.step, delta);
if delta.is_empty() {
return;
}
if delta.transferred > 0 {
crate::observability::metrics::record_phase_count(
labels.job,
labels.step,
labels.success_outcome,
delta.transferred,
None,
);
}
if delta.deletes > 0 {
crate::observability::metrics::record_phase_count(
labels.job,
labels.step,
labels.success_outcome,
delta.deletes,
None,
);
}
if delta.errors > 0 {
crate::observability::metrics::record_phase_count(
labels.job,
labels.step,
"failed",
delta.errors,
None,
);
}
if !labels.upload_metrics {
return;
}
if delta.bytes > 0 {
::metrics::counter!(
"pigeon_upload_bytes_total",
"pigeon_job" => labels.job,
"instance" => crate::observability::instance(),
"destination_bucket" => crate::observability::metrics::NO_BUCKET,
)
.increment(delta.bytes);
}
if delta.transferred > 0 {
::metrics::counter!(
"pigeon_upload_outcomes_total",
"pigeon_job" => labels.job,
"outcome" => "uploaded",
"instance" => crate::observability::instance(),
"destination_bucket" => crate::observability::metrics::NO_BUCKET,
)
.increment(delta.transferred);
}
if delta.errors > 0 {
::metrics::counter!(
"pigeon_upload_outcomes_total",
"pigeon_job" => labels.job,
"outcome" => "failed",
"instance" => crate::observability::instance(),
"destination_bucket" => crate::observability::metrics::NO_BUCKET,
)
.increment(delta.errors);
}
}
fn forward_copied(
on_copied: &Option<tokio::sync::mpsc::UnboundedSender<String>>,
objects: Vec<String>,
) {
if let Some(sender) = on_copied {
for object in objects {
let _ = sender.send(object);
}
}
}
fn log_collapsed_repeats(step: &'static str, delta: &TailDelta) {
for summary in &delta.collapsed_repeats {
tracing::warn!(
cause = %summary.cause,
first_key = ?summary.first_key,
repeated = summary.repeated,
step = step,
"rclone: {} more object failure(s) with the same cause since the last report",
summary.repeated
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_endpoint_unreachable_error_matches_the_real_dns_blackout_error() {
assert!(is_endpoint_unreachable_error(
"operation error S3: HeadObject, https response error StatusCode: 0, RequestID: , \
HostID: , request send failed, Head \
\"https://s3.fr-par.scw.cloud/pigeon-spc6f1-transform/jpg/a-abc123.jpg\": dial tcp: \
lookup s3.fr-par.scw.cloud: no such host"
));
}
#[test]
fn is_endpoint_unreachable_error_matches_the_other_connectivity_signatures() {
for err in [
"dial tcp 10.0.0.1:443: connect: connection refused",
"read tcp 10.0.0.1:443: read: connection reset by peer",
"Get \"https://example.invalid/\": net/http: TLS handshake timeout",
"dial tcp: i/o timeout",
"connect: no route to host",
"connect: network is unreachable",
] {
assert!(is_endpoint_unreachable_error(err), "{err}");
}
}
#[test]
fn is_endpoint_unreachable_error_rejects_per_file_and_auth_failures() {
for err in [
"operation error S3: PutObject, https response error StatusCode: 403, \
api error AccessDenied: Access Denied",
"operation error S3: PutObject, https response error StatusCode: 503, \
api error SlowDown: Please reduce your request rate",
"failed to open source object: open /tmp/x.jpg: no such file or directory",
"error listing: directory not found",
] {
assert!(!is_endpoint_unreachable_error(err), "{err}");
}
}
}