use anyhow::{Context, Result};
use flate2::read::GzDecoder;
use mecha10_cli_core::paths::urls::mecha10_api_base_url;
use mecha10_cli_core::utils::build_client;
use mecha10_retry::RetryConfig;
use std::fs;
use std::path::Path;
use std::time::Duration;
use tar::Archive;
#[derive(Clone, Default)]
pub struct AssetsDownloadService;
fn assets_download_retry_defaults() -> RetryConfig {
RetryConfig {
max_attempts: 4,
initial_backoff: Duration::from_millis(250),
max_backoff: Duration::from_secs(5),
backoff_multiplier: 2.0,
}
}
fn is_retryable_status(status: reqwest::StatusCode) -> bool {
status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS
}
enum AttemptError {
Retryable(anyhow::Error),
Permanent(anyhow::Error),
}
async fn try_download(client: &reqwest::Client, url: &str) -> std::result::Result<Vec<u8>, AttemptError> {
let response = client
.get(url)
.send()
.await
.context("Failed to connect to mecha10-api for mecha10-assets")
.map_err(AttemptError::Retryable)?;
let status = response.status();
if !status.is_success() {
let err = anyhow::anyhow!("Failed to download mecha10-assets: HTTP {}\nURL: {}", status, url);
return Err(if is_retryable_status(status) {
AttemptError::Retryable(err)
} else {
AttemptError::Permanent(err)
});
}
response
.bytes()
.await
.map(|bytes| bytes.to_vec())
.context("Failed to read mecha10-assets archive")
.map_err(AttemptError::Retryable)
}
impl AssetsDownloadService {
pub fn new() -> Self {
Self
}
pub async fn fetch_and_extract(&self) -> Result<tempfile::TempDir> {
self.fetch_and_extract_from(&mecha10_api_base_url()).await
}
async fn fetch_and_extract_from(&self, api_base_url: &str) -> Result<tempfile::TempDir> {
let url = format!("{}/downloads/assets", api_base_url);
let client = build_client()?;
let retry_config = assets_download_retry_defaults();
let mut delay = retry_config.initial_backoff;
let mut attempt = 1;
let bytes = loop {
match try_download(&client, &url).await {
Ok(bytes) => break bytes,
Err(AttemptError::Permanent(err)) => return Err(err),
Err(AttemptError::Retryable(err)) => {
if attempt >= retry_config.max_attempts {
return Err(err.context(format!(
"Failed to download mecha10-assets after {} attempts",
retry_config.max_attempts
)));
}
tracing::warn!(
attempt,
max_attempts = retry_config.max_attempts,
delay_ms = delay.as_millis(),
error = %err,
"mecha10-assets download failed, retrying"
);
attempt += 1;
tokio::time::sleep(delay).await;
delay = retry_config.next_delay(delay);
}
}
};
let temp_dir = tempfile::tempdir().context("Failed to create temp directory for mecha10-assets")?;
let decoder = GzDecoder::new(&bytes[..]);
let mut archive = Archive::new(decoder);
archive
.unpack(temp_dir.path())
.context("Failed to extract mecha10-assets archive")?;
tracing::info!("mecha10-assets extracted to {:?}", temp_dir.path());
Ok(temp_dir)
}
}
fn is_junk_entry(file_name: &std::ffi::OsStr) -> bool {
let name = file_name.to_string_lossy();
name.starts_with("._") || name == ".DS_Store"
}
pub fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
fs::create_dir_all(dst).with_context(|| format!("Failed to create directory {:?}", dst))?;
for entry in fs::read_dir(src).with_context(|| format!("Failed to read directory {:?}", src))? {
let entry = entry?;
if is_junk_entry(&entry.file_name()) {
continue;
}
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if src_path.is_dir() {
copy_dir_recursive(&src_path, &dst_path)?;
} else {
fs::copy(&src_path, &dst_path)
.with_context(|| format!("Failed to copy {:?} to {:?}", src_path, dst_path))?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn copy_dir_recursive_copies_nested_files_and_skips_junk_entries() {
let src = tempfile::tempdir().unwrap();
fs::create_dir_all(src.path().join("@mecha10/idle_wander")).unwrap();
fs::write(
src.path().join("@mecha10/idle_wander/config.json"),
r#"{"name": "idle_wander"}"#,
)
.unwrap();
fs::write(src.path().join("@mecha10/idle_wander/._config.json"), b"junk").unwrap();
fs::write(src.path().join(".DS_Store"), b"junk").unwrap();
let dst = tempfile::tempdir().unwrap();
copy_dir_recursive(src.path(), dst.path()).unwrap();
assert_eq!(
fs::read_to_string(dst.path().join("@mecha10/idle_wander/config.json")).unwrap(),
r#"{"name": "idle_wander"}"#
);
assert!(!dst.path().join("@mecha10/idle_wander/._config.json").exists());
assert!(!dst.path().join(".DS_Store").exists());
}
#[tokio::test]
async fn fetch_and_extract_errors_clearly_when_mecha10_api_is_unreachable() {
let service = AssetsDownloadService::new();
let err = service
.fetch_and_extract_from("http://127.0.0.1:9/api")
.await
.expect_err("unreachable mecha10-api must produce an error, not panic");
assert!(
format!("{err:#}").contains("mecha10-assets"),
"error should name what was being fetched, got: {err:#}"
);
}
fn spawn_fake_server(responses: Vec<&'static str>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
use std::net::TcpListener;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let served = Arc::new(AtomicUsize::new(0));
let served_clone = served.clone();
std::thread::spawn(move || {
use std::io::{Read, Write};
for stream in listener.incoming() {
let Ok(mut socket) = stream else { break };
let index = served_clone.fetch_add(1, Ordering::SeqCst);
let response =
responses.get(index).or_else(|| responses.last()).copied().unwrap_or(
"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
);
let mut buf = [0u8; 1024];
let _ = socket.read(&mut buf);
let _ = socket.write_all(response.as_bytes());
let _ = socket.shutdown(std::net::Shutdown::Both);
}
});
(format!("http://{}", addr), served)
}
#[tokio::test]
async fn fetch_and_extract_from_retries_transient_502_then_stops_on_permanent_404() {
let (base_url, served) = spawn_fake_server(vec![
"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
]);
let service = AssetsDownloadService::new();
let err = service
.fetch_and_extract_from(&base_url)
.await
.expect_err("eventual permanent 404 must still surface as an error");
assert!(
format!("{err:#}").contains("404"),
"expected the final 404 to surface, got: {err:#}"
);
assert_eq!(served.load(std::sync::atomic::Ordering::SeqCst), 3);
}
#[tokio::test]
async fn fetch_and_extract_from_does_not_retry_permanent_404() {
let (base_url, served) = spawn_fake_server(vec![
"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
]);
let service = AssetsDownloadService::new();
let err = service
.fetch_and_extract_from(&base_url)
.await
.expect_err("404 must be a permanent error");
assert!(format!("{err:#}").contains("404"));
assert_eq!(
served.load(std::sync::atomic::Ordering::SeqCst),
1,
"a permanent 404 must not be retried"
);
}
}