use anyhow::Result as AnyhowResult; use anyhow::anyhow; use anyhow::bail; use log::{info, warn, debug}; use md5::{Md5, Digest}; use s3::bucket::Bucket; use s3::creds::Credentials; use s3::region::Region; use s3::serde_types::Object; use std::fs::File; use std::io::BufWriter; use std::path::{PathBuf, Path}; use std::str::FromStr; use std::time::Duration; use std::{fs, io, env, thread}; const ENV_RUST_LOG : &'static str = "RUST_LOG"; // NB: 8*1024 is the same default size used by std::io, but we make it explicit. const DEFAULT_BUF_SIZE : usize = 8 * 1024; struct BucketDownloader { bucket: Bucket, destination_directory: PathBuf, temp_directory: PathBuf, } impl BucketDownloader { pub fn create( bucket: Bucket, destination_directory: &str, temp_directory: &str) -> AnyhowResult { let destination_directory = PathBuf::from(destination_directory); let temp_directory = PathBuf::from(temp_directory); Self::check_directory(&destination_directory)?; Self::check_directory(&temp_directory)?; Ok(Self { bucket, destination_directory, temp_directory, }) } fn check_directory(path: &PathBuf) -> AnyhowResult<()> { if !path.exists() { bail!("Path doesn't exist: {:?}", path); } if !path.is_dir() { bail!("Path isn't a directory: {:?}", path); } Ok(()) } pub fn download_matching(&self, path: &str) -> AnyhowResult<()> { let results = self.bucket.list_blocking("".to_string(), None)?; let mut objects = Vec::new(); for (list, code) in results { if code != 200 { bail!("Response from S3 bucket was not okay: {}", code); } if list.is_truncated { // TODO: Handle pagination warn!("Results returned are paginated. This isn't handled yet!"); } for content in list.contents { objects.push(content.clone()); } } for object in objects.iter() { info!("all objects: {:?}", object.key); } let objects : Vec = objects.into_iter() .filter(|o| o.key.starts_with(path)) .filter(|o| o.size > 0) .collect(); for object in objects.iter() { self.download_object(object)?; } Ok(()) } fn download_object(&self, object: &Object) -> AnyhowResult<()> { if self.object_already_downloaded(object) { info!("Object already downloaded (skipping): {}", object.key); return Ok(()); } self.download_object_to_temp(object)?; self.move_temp_to_downloads(object)?; Ok(()) } fn object_already_downloaded(&self, object: &Object) -> bool { let mut path = object.key.clone(); if path.starts_with("/") { path = path[1..].into(); } let download_path = self.destination_directory.join(path); if !download_path.exists() { info!("Downloadable object does not already exist at path: {:?}", download_path); return false; } let file_hash = Self::hash_file_contents(&download_path).unwrap(); // TODO let object_hash = Self::hash_object(object); // NB: Multipart-uploaded objects do not have an accurate md5 hash. // The fix for this is to copy them, which recomputes the md5 wholesale. // Read more: https://stackoverflow.com/a/29350548 if object_hash.contains("-") { warn!("Multipart hash '{}' for object {}. This will continue to download indefinitely until fixed.", object_hash, object.key); } if !file_hash.eq_ignore_ascii_case(&object_hash) { info!("File hash '{}' does not match object hash '{}' for file {}. Contents changed, and it needs to be updated.", file_hash, object_hash, object.key); return false; } true } fn download_object_to_temp(&self, object: &Object) -> AnyhowResult<()> { let temp_file_path = self.temp_download_path(object); debug!("Downloading object to temp: {} (as {:?})", object.key, temp_file_path); let temp_file = File::create(temp_file_path)?; let mut buffer = BufWriter::with_capacity(DEFAULT_BUF_SIZE, temp_file); let code = self.bucket.get_object_stream_blocking(&object.key, &mut buffer)?; match code { 200 => Ok(()), 503 => { // NB: This is a rate limiting pushback. // TODO: Handle this in a better place and way. warn!("Received a 503, which is likely rate limiting; waiting five minutes before crashing."); thread::sleep(Duration::from_secs(60 * 5)); // Waiting five minutes. Err(anyhow!("Couldn't download object to temp. Code = {}", code)) }, _ => { Err(anyhow!("Couldn't download object to temp. Code = {}", code)) } } } fn move_temp_to_downloads(&self, object: &Object) -> AnyhowResult<()> { info!("Moving temp file to permanent download directory: {}", object.key); let full_download_path = self.permanent_download_path(object); let download_directory_base = Self::base_directory(&full_download_path); if !download_directory_base.exists() { info!("Creating base download directory: {:?}", download_directory_base); fs::create_dir_all(download_directory_base)?; } else { debug!("Base download directory already exists: {:?}", download_directory_base); } let temp_file_path = self.temp_download_path(object); debug!("Renaming temp {:?} to final {:?}", temp_file_path, full_download_path); fs::rename(&temp_file_path, full_download_path)?; Ok(()) } fn temp_download_path(&self, object: &Object) -> PathBuf { let object_hash = Self::hash_object(object); self.temp_directory.join(&object_hash) } fn permanent_download_path(&self, object: &Object) -> PathBuf { self.destination_directory.join(&object.key) } fn base_directory(path: &Path) -> PathBuf { let mut path = path.to_path_buf(); path.pop(); path } fn hash_file_contents(path: &Path) -> AnyhowResult { let mut file = fs::File::open(path)?; let mut hasher = Md5::new(); let _n = io::copy(&mut file, &mut hasher)?; let hash = hasher.result(); let hex_encoded = hex::encode(hash); Ok(hex_encoded) } fn hash_object(object: &Object) -> String { // NB: E-tag contains quotes, so we remove them object.e_tag .trim_start_matches("\"") .trim_end_matches("\"") .to_string() } } // TODO: Some of these should be yaml/toml configs const ENV_ACCESS_KEY : &'static str = "ACCESS_KEY"; const ENV_SECRET_KEY : &'static str = "SECRET_KEY"; const ENV_REGION : &'static str = "REGION"; const ENV_BUCKET_NAME : &'static str = "BUCKET_NAME"; const ENV_DOWNLOAD_DIR : &'static str = "DOWNLOAD_DIR"; const ENV_TEMP_DIR : &'static str = "TEMP_DIR"; const ENV_MATCH_PATH : &'static str = "MATCH_PATH"; fn get_env(env_name: &str) -> AnyhowResult { match env::var(env_name).as_ref().ok() { None => bail!("Must set env var {}", env_name), Some(s) => Ok(s.to_string()), } } pub fn main() -> AnyhowResult<()> { if env::var(ENV_RUST_LOG) .as_ref() .ok() .is_none() { std::env::set_var(ENV_RUST_LOG, "info"); } env_logger::init(); info!("starting"); let access_key = get_env(ENV_ACCESS_KEY)?; let secret_key = get_env(ENV_SECRET_KEY)?; let region = get_env(ENV_REGION)?; let bucket_name = get_env(ENV_BUCKET_NAME)?; let download_dir = get_env(ENV_DOWNLOAD_DIR)?; let temp_dir = get_env(ENV_TEMP_DIR)?; let match_path = get_env(ENV_MATCH_PATH)?; let credentials = Credentials::new_blocking( Some(&access_key), Some(&secret_key), None, None, None, )?; let region = Region::from_str(®ion)?; let bucket = Bucket::new(&bucket_name, region, credentials)?; let bucket_downloader = BucketDownloader::create(bucket, &download_dir, &temp_dir)?; bucket_downloader.download_matching(&match_path)?; info!("Done"); Ok(()) } #[cfg(test)] mod tests { use crate::BucketDownloader; use s3::serde_types::Object; use std::path::PathBuf; #[test] fn test_hash_object() { let mut object = empty_object(); object.e_tag = String::from("\"asdf\""); // S3 objects are surrounded by quotes. assert_eq!("asdf", &BucketDownloader::hash_object(&object)); } #[test] fn test_base_directory() { let path = PathBuf::from("models/glow-tts-test/glow_tts_ljs_txlearn-2020.06.torchjit"); assert_eq!(PathBuf::from("models/glow-tts-test"), BucketDownloader::base_directory(&path)); } /// Helper function fn empty_object() -> Object { Object { last_modified: "".to_string(), e_tag: "".to_string(), storage_class: "".to_string(), key: "".to_string(), owner: None, size: 0 } } }