put: remove partial uploads when the transfer fails
A failed or interrupted upload left the already-uploaded payloads — or a truncated .changes — in the PPA's incoming area. On failure the already-uploaded files are now removed best-effort in reverse upload order with the failed file first, so a .changes never outlives the payloads it references; the original upload error keeps precedence over cleanup failures, and record-after-success semantics are unchanged (a failed upload must not count as uploaded).
This commit is contained in:
+131
-7
@@ -6,7 +6,10 @@
|
|||||||
//!
|
//!
|
||||||
//! Payload files are uploaded first and the `.changes` file last, like
|
//! Payload files are uploaded first and the `.changes` file last, like
|
||||||
//! dput does, so a partially uploaded set cannot be picked up by the
|
//! dput does, so a partially uploaded set cannot be picked up by the
|
||||||
//! server-side queue processors.
|
//! server-side queue processors. A run that fails mid-upload removes its
|
||||||
|
//! already-uploaded files from the incoming queue (best effort), so a
|
||||||
|
//! retried upload starts from a clean queue; a failed upload is never
|
||||||
|
//! recorded in the upload log, so a re-run replays every file.
|
||||||
|
|
||||||
pub mod changes;
|
pub mod changes;
|
||||||
pub mod ssh;
|
pub mod ssh;
|
||||||
@@ -147,23 +150,50 @@ pub async fn put(
|
|||||||
.to_string();
|
.to_string();
|
||||||
uploads.push((changes_path.clone(), changes_name.clone()));
|
uploads.push((changes_path.clone(), changes_name.clone()));
|
||||||
|
|
||||||
|
let incoming = target.incoming.trim_end_matches('/');
|
||||||
|
|
||||||
|
// Remote names of the files uploaded so far, in upload order (the
|
||||||
|
// .changes last). A run failing mid-upload removes these from the
|
||||||
|
// write-only incoming queue before returning: the uploaded payloads
|
||||||
|
// would otherwise linger in the queue area forever, and a .changes
|
||||||
|
// truncated by a failed close could even be picked up by the scanner.
|
||||||
|
let mut uploaded: Vec<String> = Vec::new();
|
||||||
|
|
||||||
for (path, name) in &uploads {
|
for (path, name) in &uploads {
|
||||||
let size = path
|
let size = match path.metadata() {
|
||||||
.metadata()
|
Ok(metadata) => metadata.len(),
|
||||||
.map_err(|e| format!("cannot stat '{}': {}", path.display(), e))?
|
Err(e) => {
|
||||||
.len();
|
// Nothing was attempted for this file: only what earlier
|
||||||
|
// iterations uploaded needs removing
|
||||||
|
let error: Box<dyn std::error::Error> =
|
||||||
|
format!("cannot stat '{}': {}", path.display(), e).into();
|
||||||
|
cleanup_partial_upload(&sftp, incoming, &uploaded, None, &host);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
// Same transfer view as pull: prefix line, bar on its own line
|
// Same transfer view as pull: prefix line, bar on its own line
|
||||||
let bar = multi.add(ProgressBar::new(size));
|
let bar = multi.add(ProgressBar::new(size));
|
||||||
bar.enable_steady_tick(std::time::Duration::from_millis(50));
|
bar.enable_steady_tick(std::time::Duration::from_millis(50));
|
||||||
bar.set_style(ui::transfer_style());
|
bar.set_style(ui::transfer_style());
|
||||||
bar.set_prefix(format!("Uploading {name}..."));
|
bar.set_prefix(format!("Uploading {name}..."));
|
||||||
|
|
||||||
let remote = format!("{}/{}", target.incoming.trim_end_matches('/'), name);
|
let remote = format!("{incoming}/{name}");
|
||||||
let result = ssh::upload_file(&sftp, path, &remote, &host, &bar);
|
let result = ssh::upload_file(&sftp, path, &remote, &host, &bar);
|
||||||
bar.finish_and_clear();
|
bar.finish_and_clear();
|
||||||
result?;
|
if let Err(e) = result {
|
||||||
|
// The failed file itself joins the cleanup: its remote `create`
|
||||||
|
// may have succeeded before the failure, leaving a partial — or,
|
||||||
|
// on a failed close, a truncated — file behind
|
||||||
|
cleanup_partial_upload(&sftp, incoming, &uploaded, Some(name), &host);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
uploaded.push(name.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recorded only once the whole upload succeeded: the log backs the
|
||||||
|
// duplicate-upload guard, and a failed upload must not count as
|
||||||
|
// uploaded (a re-run replays every file — `sftp.create` truncates, so
|
||||||
|
// replaying is safe).
|
||||||
record_upload(&upload_log_path()?, &record)?;
|
record_upload(&upload_log_path()?, &record)?;
|
||||||
|
|
||||||
// The completion lines replace the summary bar; the guard's own clear
|
// The completion lines replace the summary bar; the guard's own clear
|
||||||
@@ -180,6 +210,48 @@ pub async fn put(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The remote names to attempt removing after a failed upload: everything
|
||||||
|
/// already uploaded plus, when set, `failed` (the file whose upload just
|
||||||
|
/// failed — its remote `create` may have succeeded before the failure,
|
||||||
|
/// leaving a partial or truncated file behind) — in reverse upload order,
|
||||||
|
/// so a `.changes` is removed before the payloads it references and the
|
||||||
|
/// queue scanner never observes the payload set shrinking under a
|
||||||
|
/// still-present `.changes`. Pure so the ordering decision is testable
|
||||||
|
/// without a server; the network side is [`cleanup_partial_upload`].
|
||||||
|
fn cleanup_list(uploaded: &[String], failed: Option<&str>) -> Vec<String> {
|
||||||
|
let mut names: Vec<String> = uploaded.to_vec();
|
||||||
|
if let Some(failed) = failed {
|
||||||
|
names.push(failed.to_string());
|
||||||
|
}
|
||||||
|
names.reverse();
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort removal of what a failed upload left in the target's
|
||||||
|
/// incoming queue: the already-uploaded payloads, and a `.changes`
|
||||||
|
/// truncated by a failed close, would otherwise linger in the write-only
|
||||||
|
/// area until the queue is manually cleaned. Launchpad re-validates every
|
||||||
|
/// upload, so this is pure hygiene: a removal failure is logged and
|
||||||
|
/// skipped, and the caller returns the original upload error — never a
|
||||||
|
/// cleanup one.
|
||||||
|
fn cleanup_partial_upload(
|
||||||
|
sftp: &ssh2::Sftp,
|
||||||
|
incoming: &str,
|
||||||
|
uploaded: &[String],
|
||||||
|
failed: Option<&str>,
|
||||||
|
host: &str,
|
||||||
|
) {
|
||||||
|
for name in cleanup_list(uploaded, failed) {
|
||||||
|
let remote = format!("{incoming}/{name}");
|
||||||
|
match ssh::remove_file(sftp, &remote, host) {
|
||||||
|
Ok(()) => info!("Removed leftover {remote} from the failed upload"),
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Could not remove the leftover {remote} of the failed upload: {e}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Steady tick interval of every bar rendered by a `put` run
|
/// Steady tick interval of every bar rendered by a `put` run
|
||||||
const TICK: std::time::Duration = std::time::Duration::from_millis(50);
|
const TICK: std::time::Duration = std::time::Duration::from_millis(50);
|
||||||
|
|
||||||
@@ -864,4 +936,56 @@ mod tests {
|
|||||||
let changes = changes_fixture("1.7-1");
|
let changes = changes_fixture("1.7-1");
|
||||||
check_not_superseded(&target, &changes, &published, false).unwrap();
|
check_not_superseded(&target, &changes, &published, false).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The upload loop is network-bound and `ssh2::Sftp` cannot be built
|
||||||
|
/// without a live SSH session, so there is no stub seam for it without
|
||||||
|
/// heavy refactoring; the cleanup decision is factored into the pure
|
||||||
|
/// [`cleanup_list`] and tested directly instead.
|
||||||
|
///
|
||||||
|
/// Failure during the final `.changes` upload (the dangerous case: a
|
||||||
|
/// partial or close-truncated `.changes` is scanner-visible): the
|
||||||
|
/// `.changes` is removed first, then the payloads it references, so the
|
||||||
|
/// queue never sees a `.changes` over a shrinking payload set.
|
||||||
|
#[test]
|
||||||
|
fn cleanup_list_removes_changes_first_then_payloads_in_reverse() {
|
||||||
|
let uploaded = vec![
|
||||||
|
"hello_1.0.orig.tar.xz".to_string(),
|
||||||
|
"hello_1.0-1.debian.tar.xz".to_string(),
|
||||||
|
"hello_1.0-1.dsc".to_string(),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
cleanup_list(&uploaded, Some("hello_1.0-1_source.changes")),
|
||||||
|
vec![
|
||||||
|
"hello_1.0-1_source.changes",
|
||||||
|
"hello_1.0-1.dsc",
|
||||||
|
"hello_1.0-1.debian.tar.xz",
|
||||||
|
"hello_1.0.orig.tar.xz",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A failure during a payload upload: the `.changes` was never
|
||||||
|
/// attempted (it is uploaded last), so it must not appear in the
|
||||||
|
/// cleanup list — only the failed payload (a partial may exist) and the
|
||||||
|
/// earlier payloads.
|
||||||
|
#[test]
|
||||||
|
fn cleanup_list_before_the_changes_covers_the_failed_payload() {
|
||||||
|
let uploaded = vec!["hello_1.0.orig.tar.xz".to_string()];
|
||||||
|
assert_eq!(
|
||||||
|
cleanup_list(&uploaded, Some("hello_1.0-1.debian.tar.xz")),
|
||||||
|
vec!["hello_1.0-1.debian.tar.xz", "hello_1.0.orig.tar.xz"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stat failure happens before anything was attempted for that file,
|
||||||
|
/// so only what earlier iterations uploaded is removed — and with
|
||||||
|
/// nothing uploaded yet there is nothing to remove at all.
|
||||||
|
#[test]
|
||||||
|
fn cleanup_list_without_failed_file_keeps_uploaded_only() {
|
||||||
|
assert!(cleanup_list(&[], None).is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
cleanup_list(&["hello_1.0.orig.tar.xz".to_string()], None),
|
||||||
|
vec!["hello_1.0.orig.tar.xz"]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -632,6 +632,18 @@ pub fn upload_file(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove the remote file `remote` on `host` (SFTP remove). Used by the
|
||||||
|
/// best-effort cleanup of an interrupted upload: the queue is write-only,
|
||||||
|
/// so the only remote operation ever needed besides `create` is this one.
|
||||||
|
pub fn remove_file(
|
||||||
|
sftp: &ssh2::Sftp,
|
||||||
|
remote: &str,
|
||||||
|
host: &str,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
sftp.unlink(Path::new(remote))
|
||||||
|
.map_err(|e| format!("cannot remove remote file {remote} on {host}: {e}").into())
|
||||||
|
}
|
||||||
|
|
||||||
/// Open the SFTP subsystem on `session`
|
/// Open the SFTP subsystem on `session`
|
||||||
pub fn sftp(session: &Session) -> Result<ssh2::Sftp, Box<dyn std::error::Error>> {
|
pub fn sftp(session: &Session) -> Result<ssh2::Sftp, Box<dyn std::error::Error>> {
|
||||||
session
|
session
|
||||||
|
|||||||
Reference in New Issue
Block a user