Skip to main content

mecha10_templates/services/
assets_download.rs

1//! Assets download service
2//!
3//! Downloads the current published `mecha10-assets` content via the public `mecha10-api`
4//! downloads proxy.
5
6use 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
19/// Retry tuning for `mecha10-assets` downloads.
20///
21/// `mecha10-api`'s downloads proxy occasionally returns a transient `5xx` (e.g. an upstream
22/// 502 while the backing store is being redeployed) rather than a real, permanent failure.
23/// A handful of short retries smooths over that without meaningfully slowing down the common
24/// case where the first request succeeds.
25fn 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
34/// Whether an HTTP status is worth retrying, as opposed to a permanent client-side error
35/// (e.g. 404/401) that will never succeed on retry.
36fn is_retryable_status(status: reqwest::StatusCode) -> bool {
37    status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS
38}
39
40/// The outcome of a single download attempt: either the response bytes, or an error tagged
41/// with whether it's worth retrying.
42enum 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    /// Create a new AssetsDownloadService
75    pub fn new() -> Self {
76        Self
77    }
78
79    /// Downloads and extracts the current `mecha10-assets` tarball into a fresh temp directory.
80    ///
81    /// Returns the [`tempfile::TempDir`] handle itself so the caller can keep it alive for as
82    /// long as it needs the extracted content (the directory is removed when the handle drops).
83    pub async fn fetch_and_extract(&self) -> Result<tempfile::TempDir> {
84        self.fetch_and_extract_from(&mecha10_api_base_url()).await
85    }
86
87    /// Does the actual fetch/extract work against an explicit `mecha10-api` base URL, so tests
88    /// can exercise the error path without mutating the process-global `MECHA10_API_URL` env var.
89    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
138/// Skip macOS AppleDouble/resource-fork junk (`._*`, `.DS_Store`) that can leak into release
139/// tarballs when packaged on a Mac without `COPYFILE_DISABLE=1`.
140fn 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
145/// Recursively copy a directory from an extracted `mecha10-assets` tree into a project
146/// destination. Mirrors [`crate::services::template_download`]'s own tree-walk helper, since
147/// there's no version-keyed cache layer here to abstract the copy behind.
148pub 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    /// Minimal raw-TCP HTTP server for exercising retry behavior without pulling in a mock-HTTP
213    /// dependency. `responses` is replayed in order, one per accepted connection; once exhausted
214    /// the last entry is repeated for any further requests. Returns the server's base URL plus
215    /// a shared counter of how many requests it has served so far.
216    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        // Retried through both 502s (attempts 1 and 2), then hit the permanent 404 on attempt 3
266        // and stopped immediately rather than exhausting all `max_attempts`.
267        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}