mecha10_templates/services/
assets_download.rs1use anyhow::{Context, Result};
7use flate2::read::GzDecoder;
8use mecha10_cli_core::paths::urls::mecha10_api_base_url;
9use mecha10_cli_core::utils::build_client;
10use mecha10_retry::RetryConfig;
11use std::fs;
12use std::path::Path;
13use std::time::Duration;
14use tar::Archive;
15
16#[derive(Clone, Default)]
17pub struct AssetsDownloadService;
18
19fn assets_download_retry_defaults() -> RetryConfig {
26 RetryConfig {
27 max_attempts: 4,
28 initial_backoff: Duration::from_millis(250),
29 max_backoff: Duration::from_secs(5),
30 backoff_multiplier: 2.0,
31 }
32}
33
34fn is_retryable_status(status: reqwest::StatusCode) -> bool {
37 status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS
38}
39
40enum AttemptError {
43 Retryable(anyhow::Error),
44 Permanent(anyhow::Error),
45}
46
47async fn try_download(client: &reqwest::Client, url: &str) -> std::result::Result<Vec<u8>, AttemptError> {
48 let response = client
49 .get(url)
50 .send()
51 .await
52 .context("Failed to connect to mecha10-api for mecha10-assets")
53 .map_err(AttemptError::Retryable)?;
54
55 let status = response.status();
56 if !status.is_success() {
57 let err = anyhow::anyhow!("Failed to download mecha10-assets: HTTP {}\nURL: {}", status, url);
58 return Err(if is_retryable_status(status) {
59 AttemptError::Retryable(err)
60 } else {
61 AttemptError::Permanent(err)
62 });
63 }
64
65 response
66 .bytes()
67 .await
68 .map(|bytes| bytes.to_vec())
69 .context("Failed to read mecha10-assets archive")
70 .map_err(AttemptError::Retryable)
71}
72
73impl AssetsDownloadService {
74 pub fn new() -> Self {
76 Self
77 }
78
79 pub async fn fetch_and_extract(&self) -> Result<tempfile::TempDir> {
84 self.fetch_and_extract_from(&mecha10_api_base_url()).await
85 }
86
87 async fn fetch_and_extract_from(&self, api_base_url: &str) -> Result<tempfile::TempDir> {
90 let url = format!("{}/downloads/assets", api_base_url);
91
92 let client = build_client()?;
93 let retry_config = assets_download_retry_defaults();
94 let mut delay = retry_config.initial_backoff;
95 let mut attempt = 1;
96
97 let bytes = loop {
98 match try_download(&client, &url).await {
99 Ok(bytes) => break bytes,
100 Err(AttemptError::Permanent(err)) => return Err(err),
101 Err(AttemptError::Retryable(err)) => {
102 if attempt >= retry_config.max_attempts {
103 return Err(err.context(format!(
104 "Failed to download mecha10-assets after {} attempts",
105 retry_config.max_attempts
106 )));
107 }
108
109 tracing::warn!(
110 attempt,
111 max_attempts = retry_config.max_attempts,
112 delay_ms = delay.as_millis(),
113 error = %err,
114 "mecha10-assets download failed, retrying"
115 );
116 attempt += 1;
117 tokio::time::sleep(delay).await;
118 delay = retry_config.next_delay(delay);
119 }
120 }
121 };
122
123 let temp_dir = tempfile::tempdir().context("Failed to create temp directory for mecha10-assets")?;
124
125 let decoder = GzDecoder::new(&bytes[..]);
126 let mut archive = Archive::new(decoder);
127
128 archive
129 .unpack(temp_dir.path())
130 .context("Failed to extract mecha10-assets archive")?;
131
132 tracing::info!("mecha10-assets extracted to {:?}", temp_dir.path());
133
134 Ok(temp_dir)
135 }
136}
137
138fn is_junk_entry(file_name: &std::ffi::OsStr) -> bool {
141 let name = file_name.to_string_lossy();
142 name.starts_with("._") || name == ".DS_Store"
143}
144
145pub fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
149 fs::create_dir_all(dst).with_context(|| format!("Failed to create directory {:?}", dst))?;
150
151 for entry in fs::read_dir(src).with_context(|| format!("Failed to read directory {:?}", src))? {
152 let entry = entry?;
153 if is_junk_entry(&entry.file_name()) {
154 continue;
155 }
156 let src_path = entry.path();
157 let dst_path = dst.join(entry.file_name());
158
159 if src_path.is_dir() {
160 copy_dir_recursive(&src_path, &dst_path)?;
161 } else {
162 fs::copy(&src_path, &dst_path)
163 .with_context(|| format!("Failed to copy {:?} to {:?}", src_path, dst_path))?;
164 }
165 }
166
167 Ok(())
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 #[test]
175 fn copy_dir_recursive_copies_nested_files_and_skips_junk_entries() {
176 let src = tempfile::tempdir().unwrap();
177 fs::create_dir_all(src.path().join("@mecha10/idle_wander")).unwrap();
178 fs::write(
179 src.path().join("@mecha10/idle_wander/config.json"),
180 r#"{"name": "idle_wander"}"#,
181 )
182 .unwrap();
183 fs::write(src.path().join("@mecha10/idle_wander/._config.json"), b"junk").unwrap();
184 fs::write(src.path().join(".DS_Store"), b"junk").unwrap();
185
186 let dst = tempfile::tempdir().unwrap();
187 copy_dir_recursive(src.path(), dst.path()).unwrap();
188
189 assert_eq!(
190 fs::read_to_string(dst.path().join("@mecha10/idle_wander/config.json")).unwrap(),
191 r#"{"name": "idle_wander"}"#
192 );
193 assert!(!dst.path().join("@mecha10/idle_wander/._config.json").exists());
194 assert!(!dst.path().join(".DS_Store").exists());
195 }
196
197 #[tokio::test]
198 async fn fetch_and_extract_errors_clearly_when_mecha10_api_is_unreachable() {
199 let service = AssetsDownloadService::new();
200
201 let err = service
202 .fetch_and_extract_from("http://127.0.0.1:9/api")
203 .await
204 .expect_err("unreachable mecha10-api must produce an error, not panic");
205
206 assert!(
207 format!("{err:#}").contains("mecha10-assets"),
208 "error should name what was being fetched, got: {err:#}"
209 );
210 }
211
212 fn spawn_fake_server(responses: Vec<&'static str>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
217 use std::net::TcpListener;
218 use std::sync::atomic::{AtomicUsize, Ordering};
219 use std::sync::Arc;
220
221 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
222 let addr = listener.local_addr().unwrap();
223 let served = Arc::new(AtomicUsize::new(0));
224 let served_clone = served.clone();
225
226 std::thread::spawn(move || {
227 use std::io::{Read, Write};
228
229 for stream in listener.incoming() {
230 let Ok(mut socket) = stream else { break };
231 let index = served_clone.fetch_add(1, Ordering::SeqCst);
232 let response =
233 responses.get(index).or_else(|| responses.last()).copied().unwrap_or(
234 "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
235 );
236
237 let mut buf = [0u8; 1024];
238 let _ = socket.read(&mut buf);
239 let _ = socket.write_all(response.as_bytes());
240 let _ = socket.shutdown(std::net::Shutdown::Both);
241 }
242 });
243
244 (format!("http://{}", addr), served)
245 }
246
247 #[tokio::test]
248 async fn fetch_and_extract_from_retries_transient_502_then_stops_on_permanent_404() {
249 let (base_url, served) = spawn_fake_server(vec![
250 "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
251 "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
252 "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
253 ]);
254
255 let service = AssetsDownloadService::new();
256 let err = service
257 .fetch_and_extract_from(&base_url)
258 .await
259 .expect_err("eventual permanent 404 must still surface as an error");
260
261 assert!(
262 format!("{err:#}").contains("404"),
263 "expected the final 404 to surface, got: {err:#}"
264 );
265 assert_eq!(served.load(std::sync::atomic::Ordering::SeqCst), 3);
268 }
269
270 #[tokio::test]
271 async fn fetch_and_extract_from_does_not_retry_permanent_404() {
272 let (base_url, served) = spawn_fake_server(vec![
273 "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
274 ]);
275
276 let service = AssetsDownloadService::new();
277 let err = service
278 .fetch_and_extract_from(&base_url)
279 .await
280 .expect_err("404 must be a permanent error");
281
282 assert!(format!("{err:#}").contains("404"));
283 assert_eq!(
284 served.load(std::sync::atomic::Ordering::SeqCst),
285 1,
286 "a permanent 404 must not be retried"
287 );
288 }
289}