Skip to main content

mecha10_deploy/services/
binary_manager.rs

1//! Binary Manager service for downloading and caching node binaries via `mecha10-api`
2//!
3//! This service implements the Node Binary Distribution architecture, providing:
4//! - Downloading pre-built node binaries via the public `mecha10-api` downloads proxy
5//!   (`GET /api/downloads/node/{name}`), never the private Minio instance directly
6//! - Caching binaries to `~/.mecha10/bin/`
7//! - Platform detection and target triple resolution
8//!
9//! There is a single resolution path: a cached binary if one already exists, otherwise a
10//! download via `mecha10-api`. There is no `cargo install` fallback — an unsupported platform
11//! or a failed download is a hard, immediate error naming what was tried and why it failed.
12//!
13//! # Design
14//!
15//! - Binaries are downloaded from `{mecha10_api}/downloads/node/{node}?os=..&arch=..&version=..`
16//! - Binary naming: `{node}-{version}-{target}.tar.gz`
17//! - Cached to: `~/.mecha10/bin/{node}-{version}-{target}`
18//! - Symlink created: `~/.mecha10/bin/{node}` -> latest version
19//!
20//! # Usage
21//!
22//! ```no_run
23//! use mecha10_deploy::services::BinaryManager;
24//!
25//! # async fn example() -> anyhow::Result<()> {
26//! let manager = BinaryManager::new()?;
27//!
28//! // Resolve binary path (downloads if needed)
29//! let path = manager.resolve("speaker").await?;
30//!
31//! // Execute the binary
32//! std::process::Command::new(&path).spawn()?;
33//! # Ok(())
34//! # }
35//! ```
36
37#![allow(dead_code)]
38
39use crate::paths;
40use anyhow::{Context, Result};
41use indicatif::{ProgressBar, ProgressStyle};
42use mecha10_cli_core::utils::build_client;
43use std::path::PathBuf;
44
45const FRAMEWORK_VERSION: &str = env!("CARGO_PKG_VERSION");
46
47pub struct BinaryManager {
48    cache_dir: PathBuf,
49    version: String,
50    target: String,
51}
52
53impl BinaryManager {
54    /// Create a new binary manager
55    pub fn new() -> Result<Self> {
56        let cache_dir = paths::user::bin_dir();
57        let version = FRAMEWORK_VERSION.to_string();
58        let target = mecha10_binary_naming::detect_target()?;
59
60        Ok(Self {
61            cache_dir,
62            version,
63            target,
64        })
65    }
66
67    /// Create a binary manager with a specific version
68    #[allow(dead_code)]
69    pub fn with_version(version: String) -> Result<Self> {
70        let cache_dir = paths::user::bin_dir();
71        let target = mecha10_binary_naming::detect_target()?;
72
73        Ok(Self {
74            cache_dir,
75            version,
76            target,
77        })
78    }
79
80    /// Check if pre-built binaries are available for current platform
81    pub fn is_prebuilt_available(&self) -> bool {
82        mecha10_binary_naming::is_prebuilt_available(&self.target)
83    }
84
85    /// Get the binary cache directory
86    #[allow(dead_code)]
87    pub fn cache_dir(&self) -> &PathBuf {
88        &self.cache_dir
89    }
90
91    /// Get the current version
92    #[allow(dead_code)]
93    pub fn version(&self) -> &str {
94        &self.version
95    }
96
97    /// Get the current target
98    #[allow(dead_code)]
99    pub fn target(&self) -> &str {
100        &self.target
101    }
102
103    /// Resolve binary path for a node, downloading if needed
104    ///
105    /// Resolution strategy:
106    /// 1. Check if a cached binary exists for the current version/target
107    /// 2. If not, download it from `mecha10-api`
108    ///
109    /// There is no further fallback: if the platform has no pre-built binary, or the
110    /// download fails, this returns an error immediately instead of silently degrading to
111    /// a slower/older resolution path.
112    ///
113    /// # Arguments
114    ///
115    /// * `node_name` - Name of the node (e.g., "speaker", "motor")
116    ///
117    /// # Returns
118    ///
119    /// Path to the executable binary
120    pub async fn resolve(&self, node_name: &str) -> Result<PathBuf> {
121        self.resolve_with_version(node_name, None).await
122    }
123
124    /// Resolve binary path for a node with a specific version
125    ///
126    /// Resolution strategy (`LAB-4558`: the ordered-tiers-with-aggregate-error control flow
127    /// below is shared via `mecha10_binary_naming::try_resolution_tiers!` with
128    /// `NodeResolver::resolve_with_version` and the CLI's `resolve_cli_binary` - only the
129    /// concrete tiers here are specific to this type):
130    /// 1. Check if a cached binary exists for the current version/target
131    /// 2. If not, download it from `mecha10-api`
132    ///
133    /// There is no further fallback: if the platform has no pre-built binary, or the
134    /// download fails, this returns an error immediately instead of silently degrading to
135    /// a slower/older resolution path.
136    ///
137    /// # Arguments
138    ///
139    /// * `node_name` - Name of the node (e.g., "speaker", "motor")
140    /// * `version` - Optional specific version to download (e.g., "0.1.48").
141    ///   If None, downloads the latest available version.
142    ///
143    /// # Returns
144    ///
145    /// Path to the executable binary
146    pub async fn resolve_with_version(&self, node_name: &str, version: Option<&str>) -> Result<PathBuf> {
147        mecha10_binary_naming::try_resolution_tiers!(node_name, {
148            "cached binary" => match version {
149                Some(v) => self.find_cached_version(node_name, v),
150                None => self.find_cached(node_name),
151            }
152            .inspect(|path| {
153                tracing::debug!("Using cached binary: {}", path.display());
154            })
155            .ok_or_else(|| "no cached binary found for this version/target".to_string()),
156
157            "mecha10-api download" => {
158                if !self.is_prebuilt_available() {
159                    Err(format!(
160                        "No pre-built binary available for '{}' on platform '{}' (supported targets: {}). \
161                         Binaries are resolved solely via mecha10-api downloads ({}); there is no \
162                         cargo-install or build-from-source fallback.",
163                        node_name,
164                        self.target,
165                        mecha10_binary_naming::SUPPORTED_TARGETS.join(", "),
166                        paths::urls::mecha10_api_base_url(),
167                    ))
168                } else {
169                    let result = if let Some(v) = version {
170                        self.download_version(node_name, v).await
171                    } else {
172                        self.download(node_name).await
173                    };
174
175                    result
176                        .inspect(|path| {
177                            tracing::info!("Downloaded binary for {}: {}", node_name, path.display());
178                        })
179                        .map_err(|e| {
180                            format!(
181                                "Failed to resolve binary for '{}' via mecha10-api download: {:#}",
182                                node_name, e
183                            )
184                        })
185                }
186            },
187        })
188        .map_err(|e| anyhow::anyhow!(e))
189    }
190
191    /// Check for cached binary with specific version
192    fn find_cached_version(&self, node_name: &str, version: &str) -> Option<PathBuf> {
193        mecha10_binary_naming::find_cached_version(&self.cache_dir, node_name, version, &self.target)
194    }
195
196    /// Check for cached binary (any version)
197    ///
198    /// Looks for any cached version of the binary, preferring the symlink which
199    /// points to the most recently downloaded version.
200    fn find_cached(&self, node_name: &str) -> Option<PathBuf> {
201        mecha10_binary_naming::find_cached(&self.cache_dir, node_name, &self.target)
202    }
203
204    /// Build the binary name for a node with a specific version
205    fn binary_name_with_version(&self, node_name: &str, version: &str) -> String {
206        mecha10_binary_naming::binary_name(node_name, version, &self.target)
207    }
208
209    /// Build the binary name for a node (uses current version)
210    fn binary_name(&self, node_name: &str) -> String {
211        self.binary_name_with_version(node_name, &self.version)
212    }
213
214    /// Build the tarball name for a node with a specific version
215    fn tarball_name_with_version(&self, node_name: &str, version: &str) -> String {
216        mecha10_binary_naming::tarball_name(node_name, version, &self.target)
217    }
218
219    /// Build the tarball name for a node (uses current version)
220    #[allow(dead_code)]
221    fn tarball_name(&self, node_name: &str) -> String {
222        self.tarball_name_with_version(node_name, &self.version)
223    }
224
225    /// Download binary at the current framework version via mecha10-api
226    async fn download(&self, node_name: &str) -> Result<PathBuf> {
227        self.download_version(node_name, &self.version.clone()).await
228    }
229
230    /// Download a specific version of a node binary via `mecha10-api`
231    async fn download_version(&self, node_name: &str, version: &str) -> Result<PathBuf> {
232        let tarball = self.tarball_name_with_version(node_name, version);
233        let (os, arch) = mecha10_binary_naming::os_arch_query(&self.target)?;
234        let url = format!(
235            "{}/downloads/node/{}?os={}&arch={}&version={}",
236            paths::urls::mecha10_api_base_url(),
237            node_name,
238            os,
239            arch,
240            version
241        );
242
243        println!("📦 Downloading {} v{}...", node_name, version);
244        tracing::info!("Downloading binary from mecha10-api: {}", url);
245
246        let client = build_client()?;
247        let response = client
248            .get(&url)
249            .send()
250            .await
251            .context(format!("Failed to connect to mecha10-api for binary '{}'", node_name))?;
252
253        if !response.status().is_success() {
254            return Err(anyhow::anyhow!(
255                "Binary '{}' not found: HTTP {}\n\
256                 URL: {}",
257                tarball,
258                response.status(),
259                url
260            ));
261        }
262
263        let size = response.content_length().unwrap_or(0);
264        self.download_and_extract_versioned(node_name, version, response, size)
265            .await
266    }
267
268    /// Download and extract a binary tarball with a specific version
269    async fn download_and_extract_versioned(
270        &self,
271        node_name: &str,
272        version: &str,
273        response: reqwest::Response,
274        size: u64,
275    ) -> Result<PathBuf> {
276        let pb = ProgressBar::new(size);
277        pb.set_style(
278            ProgressStyle::default_bar()
279                .template("{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
280                .unwrap()
281                .progress_chars("#>-"),
282        );
283
284        let temp_dir = tempfile::tempdir()?;
285        let temp_file = temp_dir.path().join("binary.tar.gz");
286
287        let mut file = tokio::fs::File::create(&temp_file).await?;
288        let mut stream = response.bytes_stream();
289
290        use futures_util::StreamExt;
291        use tokio::io::AsyncWriteExt;
292
293        while let Some(chunk) = stream.next().await {
294            let chunk = chunk.context("Error downloading chunk")?;
295            file.write_all(&chunk).await?;
296            pb.inc(chunk.len() as u64);
297        }
298
299        file.flush().await?;
300        pb.finish_with_message("Download complete");
301
302        tokio::fs::create_dir_all(&self.cache_dir).await?;
303
304        let tar_gz = std::fs::File::open(&temp_file)?;
305        let tar = flate2::read::GzDecoder::new(tar_gz);
306        let mut archive = tar::Archive::new(tar);
307
308        let extract_dir = temp_dir.path().join("extract");
309        std::fs::create_dir_all(&extract_dir)?;
310        archive.unpack(&extract_dir)?;
311
312        let extracted_binary = extract_dir.join(node_name);
313        if !extracted_binary.exists() {
314            return Err(anyhow::anyhow!("Binary '{}' not found in tarball", node_name));
315        }
316
317        let binary_name = self.binary_name_with_version(node_name, version);
318        let dest_path = self.cache_dir.join(&binary_name);
319
320        tokio::fs::copy(&extracted_binary, &dest_path).await?;
321
322        #[cfg(unix)]
323        {
324            use std::os::unix::fs::PermissionsExt;
325            let mut perms = tokio::fs::metadata(&dest_path).await?.permissions();
326            perms.set_mode(0o755);
327            tokio::fs::set_permissions(&dest_path, perms).await?;
328        }
329
330        let symlink_path = self.cache_dir.join(node_name);
331        if symlink_path.exists() || symlink_path.is_symlink() {
332            tokio::fs::remove_file(&symlink_path).await.ok();
333        }
334
335        #[cfg(unix)]
336        {
337            std::os::unix::fs::symlink(&binary_name, &symlink_path)?;
338        }
339
340        #[cfg(windows)]
341        {
342            std::os::windows::fs::symlink_file(&dest_path, &symlink_path)?;
343        }
344
345        println!("✅ Installed {} v{} to {}", node_name, version, dest_path.display());
346
347        Ok(dest_path)
348    }
349
350    /// Remove a cached binary
351    pub async fn remove(&self, node_name: &str) -> Result<()> {
352        let binary_name = self.binary_name(node_name);
353        let binary_path = self.cache_dir.join(&binary_name);
354        let symlink_path = self.cache_dir.join(node_name);
355
356        if binary_path.exists() {
357            tokio::fs::remove_file(&binary_path).await?;
358            println!("Removed {}", binary_path.display());
359        }
360
361        if symlink_path.exists() || symlink_path.is_symlink() {
362            tokio::fs::remove_file(&symlink_path).await?;
363            println!("Removed symlink {}", symlink_path.display());
364        }
365
366        Ok(())
367    }
368
369    /// Clear all cached binaries
370    pub async fn clear_cache(&self) -> Result<()> {
371        if self.cache_dir.exists() {
372            tokio::fs::remove_dir_all(&self.cache_dir).await?;
373            println!("Cleared binary cache at {}", self.cache_dir.display());
374        }
375        Ok(())
376    }
377}
378
379impl Default for BinaryManager {
380    fn default() -> Self {
381        Self::new().expect("Failed to create BinaryManager")
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    #[test]
390    fn test_binary_name() {
391        let manager = BinaryManager {
392            cache_dir: PathBuf::from("/tmp"),
393            version: "0.1.44".to_string(),
394            target: "aarch64-apple-darwin".to_string(),
395        };
396
397        assert_eq!(manager.binary_name("speaker"), "speaker-0.1.44-aarch64-apple-darwin");
398    }
399
400    #[test]
401    fn test_tarball_name() {
402        let manager = BinaryManager {
403            cache_dir: PathBuf::from("/tmp"),
404            version: "0.1.44".to_string(),
405            target: "x86_64-apple-darwin".to_string(),
406        };
407
408        assert_eq!(manager.tarball_name("motor"), "motor-0.1.44-x86_64-apple-darwin.tar.gz");
409    }
410
411    #[tokio::test]
412    async fn resolve_errors_immediately_on_unsupported_platform_with_no_cargo_install_fallback() {
413        let manager = BinaryManager {
414            cache_dir: PathBuf::from("/tmp/mecha10-binary-manager-test-does-not-exist"),
415            version: "0.1.44".to_string(),
416            target: "aarch64-unknown-linux-musl".to_string(),
417        };
418
419        let err = manager
420            .resolve("speaker")
421            .await
422            .expect_err("unsupported platform must error, not silently fall back");
423
424        let message = err.to_string();
425        assert!(
426            message.contains("No pre-built binary available"),
427            "error should name the missing source, got: {message}"
428        );
429        assert!(
430            message.contains("no cargo-install or build-from-source fallback"),
431            "error must make clear there is no fallback path, got: {message}"
432        );
433    }
434
435    #[test]
436    fn test_prebuilt_available() {
437        let manager = BinaryManager {
438            cache_dir: PathBuf::from("/tmp"),
439            version: "0.1.44".to_string(),
440            target: "aarch64-apple-darwin".to_string(),
441        };
442        assert!(manager.is_prebuilt_available());
443
444        let manager_arm_linux = BinaryManager {
445            cache_dir: PathBuf::from("/tmp"),
446            version: "0.1.44".to_string(),
447            target: "aarch64-unknown-linux-gnu".to_string(),
448        };
449        assert!(manager_arm_linux.is_prebuilt_available());
450
451        let manager_unsupported = BinaryManager {
452            cache_dir: PathBuf::from("/tmp"),
453            version: "0.1.44".to_string(),
454            target: "aarch64-unknown-linux-musl".to_string(),
455        };
456        assert!(!manager_unsupported.is_prebuilt_available());
457    }
458}