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//! - Fallback to cargo install for unsupported platforms
9//!
10//! # Design
11//!
12//! - Binaries are downloaded from `{mecha10_api}/downloads/node/{node}?os=..&arch=..&version=..`
13//! - Binary naming: `{node}-{version}-{target}.tar.gz`
14//! - Cached to: `~/.mecha10/bin/{node}-{version}-{target}`
15//! - Symlink created: `~/.mecha10/bin/{node}` -> latest version
16//!
17//! # Usage
18//!
19//! ```no_run
20//! use mecha10_deploy::services::BinaryManager;
21//!
22//! # async fn example() -> anyhow::Result<()> {
23//! let manager = BinaryManager::new()?;
24//!
25//! // Resolve binary path (downloads if needed)
26//! let path = manager.resolve("speaker").await?;
27//!
28//! // Execute the binary
29//! std::process::Command::new(&path).spawn()?;
30//! # Ok(())
31//! # }
32//! ```
33
34#![allow(dead_code)]
35
36use crate::paths;
37use anyhow::{Context, Result};
38use indicatif::{ProgressBar, ProgressStyle};
39use std::path::PathBuf;
40
41/// Framework version (should match workspace Cargo.toml)
42const FRAMEWORK_VERSION: &str = env!("CARGO_PKG_VERSION");
43
44/// Service for managing node binary downloads and caching
45pub struct BinaryManager {
46    /// Binary cache directory (~/.mecha10/bin)
47    cache_dir: PathBuf,
48    /// Framework version for binary resolution
49    version: String,
50    /// Current platform target triple
51    target: String,
52}
53
54impl BinaryManager {
55    /// Create a new binary manager
56    pub fn new() -> Result<Self> {
57        let cache_dir = paths::user::bin_dir();
58        let version = FRAMEWORK_VERSION.to_string();
59        let target = mecha10_binary_naming::detect_target()?;
60
61        Ok(Self {
62            cache_dir,
63            version,
64            target,
65        })
66    }
67
68    /// Create a binary manager with a specific version
69    #[allow(dead_code)]
70    pub fn with_version(version: String) -> Result<Self> {
71        let cache_dir = paths::user::bin_dir();
72        let target = mecha10_binary_naming::detect_target()?;
73
74        Ok(Self {
75            cache_dir,
76            version,
77            target,
78        })
79    }
80
81    /// Check if pre-built binaries are available for current platform
82    pub fn is_prebuilt_available(&self) -> bool {
83        mecha10_binary_naming::is_prebuilt_available(&self.target)
84    }
85
86    /// Get the binary cache directory
87    #[allow(dead_code)]
88    pub fn cache_dir(&self) -> &PathBuf {
89        &self.cache_dir
90    }
91
92    /// Get the current version
93    #[allow(dead_code)]
94    pub fn version(&self) -> &str {
95        &self.version
96    }
97
98    /// Get the current target
99    #[allow(dead_code)]
100    pub fn target(&self) -> &str {
101        &self.target
102    }
103
104    /// Resolve binary path for a node, downloading if needed
105    ///
106    /// Resolution strategy:
107    /// 1. Check if cached binary exists for current version/target
108    /// 2. If not, try to download from GitHub releases
109    /// 3. If download fails and platform unsupported, fall back to cargo install
110    ///
111    /// # Arguments
112    ///
113    /// * `node_name` - Name of the node (e.g., "speaker", "motor")
114    ///
115    /// # Returns
116    ///
117    /// Path to the executable binary
118    pub async fn resolve(&self, node_name: &str) -> Result<PathBuf> {
119        self.resolve_with_version(node_name, None).await
120    }
121
122    /// Resolve binary path for a node with a specific version
123    ///
124    /// # Arguments
125    ///
126    /// * `node_name` - Name of the node (e.g., "speaker", "motor")
127    /// * `version` - Optional specific version to download (e.g., "0.1.48").
128    ///   If None, downloads the latest available version.
129    ///
130    /// # Returns
131    ///
132    /// Path to the executable binary
133    pub async fn resolve_with_version(&self, node_name: &str, version: Option<&str>) -> Result<PathBuf> {
134        // Check cache first (for specific version if provided)
135        if let Some(v) = version {
136            if let Some(path) = self.find_cached_version(node_name, v) {
137                tracing::debug!("Using cached binary: {}", path.display());
138                return Ok(path);
139            }
140        } else if let Some(cached) = self.find_cached(node_name) {
141            tracing::debug!("Using cached binary: {}", cached.display());
142            return Ok(cached);
143        }
144
145        // Try to download
146        if self.is_prebuilt_available() {
147            let result = if let Some(v) = version {
148                self.download_version(node_name, v).await
149            } else {
150                self.download(node_name).await
151            };
152
153            match result {
154                Ok(path) => {
155                    tracing::info!("Downloaded binary for {}: {}", node_name, path.display());
156                    return Ok(path);
157                }
158                Err(e) => {
159                    tracing::warn!("Failed to download binary for {}: {}", node_name, e);
160                    // Show actionable message for macOS (binaries built manually)
161                    if self.target.ends_with("-apple-darwin") {
162                        println!("⚠️  macOS binaries for '{}' v{} not found.", node_name, self.version);
163                        println!("   Compiling from source instead (this may take a few minutes)...");
164                    }
165                    // Fall through to cargo install
166                }
167            }
168        } else {
169            tracing::info!("Pre-built binary not available for {} on {}", node_name, self.target);
170        }
171
172        // Fallback: cargo install (uses framework version, not specified version)
173        self.cargo_install(node_name).await
174    }
175
176    /// Check for cached binary with specific version
177    fn find_cached_version(&self, node_name: &str, version: &str) -> Option<PathBuf> {
178        mecha10_binary_naming::find_cached_version(&self.cache_dir, node_name, version, &self.target)
179    }
180
181    /// Check for cached binary (any version)
182    ///
183    /// Looks for any cached version of the binary, preferring the symlink which
184    /// points to the most recently downloaded version.
185    fn find_cached(&self, node_name: &str) -> Option<PathBuf> {
186        mecha10_binary_naming::find_cached(&self.cache_dir, node_name, &self.target)
187    }
188
189    /// Build the binary name for a node with a specific version
190    fn binary_name_with_version(&self, node_name: &str, version: &str) -> String {
191        mecha10_binary_naming::binary_name(node_name, version, &self.target)
192    }
193
194    /// Build the binary name for a node (uses current version)
195    fn binary_name(&self, node_name: &str) -> String {
196        self.binary_name_with_version(node_name, &self.version)
197    }
198
199    /// Build the tarball name for a node with a specific version
200    fn tarball_name_with_version(&self, node_name: &str, version: &str) -> String {
201        mecha10_binary_naming::tarball_name(node_name, version, &self.target)
202    }
203
204    /// Build the tarball name for a node (uses current version)
205    #[allow(dead_code)]
206    fn tarball_name(&self, node_name: &str) -> String {
207        self.tarball_name_with_version(node_name, &self.version)
208    }
209
210    /// Download binary at the current framework version via mecha10-api
211    async fn download(&self, node_name: &str) -> Result<PathBuf> {
212        self.download_version(node_name, &self.version.clone()).await
213    }
214
215    /// Download a specific version of a node binary via `mecha10-api`
216    async fn download_version(&self, node_name: &str, version: &str) -> Result<PathBuf> {
217        let tarball = self.tarball_name_with_version(node_name, version);
218        let (os, arch) = mecha10_binary_naming::os_arch_query(&self.target)?;
219        let url = format!(
220            "{}/downloads/node/{}?os={}&arch={}&version={}",
221            paths::urls::mecha10_api_base_url(),
222            node_name,
223            os,
224            arch,
225            version
226        );
227
228        println!("📦 Downloading {} v{}...", node_name, version);
229        tracing::info!("Downloading binary from mecha10-api: {}", url);
230
231        let client = Self::build_client()?;
232        let response = client
233            .get(&url)
234            .send()
235            .await
236            .context(format!("Failed to connect to mecha10-api for binary '{}'", node_name))?;
237
238        if !response.status().is_success() {
239            return Err(anyhow::anyhow!(
240                "Binary '{}' not found: HTTP {}\n\
241                 URL: {}\n\
242                 Falling back to cargo install...",
243                tarball,
244                response.status(),
245                url
246            ));
247        }
248
249        let size = response.content_length().unwrap_or(0);
250        self.download_and_extract_versioned(node_name, version, response, size)
251            .await
252    }
253
254    /// Download and extract a binary tarball with a specific version
255    async fn download_and_extract_versioned(
256        &self,
257        node_name: &str,
258        version: &str,
259        response: reqwest::Response,
260        size: u64,
261    ) -> Result<PathBuf> {
262        // Create progress bar
263        let pb = ProgressBar::new(size);
264        pb.set_style(
265            ProgressStyle::default_bar()
266                .template("{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
267                .unwrap()
268                .progress_chars("#>-"),
269        );
270
271        // Download to temp file
272        let temp_dir = tempfile::tempdir()?;
273        let temp_file = temp_dir.path().join("binary.tar.gz");
274
275        let mut file = tokio::fs::File::create(&temp_file).await?;
276        let mut stream = response.bytes_stream();
277
278        use futures_util::StreamExt;
279        use tokio::io::AsyncWriteExt;
280
281        while let Some(chunk) = stream.next().await {
282            let chunk = chunk.context("Error downloading chunk")?;
283            file.write_all(&chunk).await?;
284            pb.inc(chunk.len() as u64);
285        }
286
287        file.flush().await?;
288        pb.finish_with_message("Download complete");
289
290        // Create cache directory
291        tokio::fs::create_dir_all(&self.cache_dir).await?;
292
293        // Extract tarball
294        let tar_gz = std::fs::File::open(&temp_file)?;
295        let tar = flate2::read::GzDecoder::new(tar_gz);
296        let mut archive = tar::Archive::new(tar);
297
298        // Extract to temp, then move to cache
299        let extract_dir = temp_dir.path().join("extract");
300        std::fs::create_dir_all(&extract_dir)?;
301        archive.unpack(&extract_dir)?;
302
303        // Find the binary in the extracted files (should be just the node name)
304        let extracted_binary = extract_dir.join(node_name);
305        if !extracted_binary.exists() {
306            return Err(anyhow::anyhow!("Binary '{}' not found in tarball", node_name));
307        }
308
309        // Move to cache with versioned name
310        let binary_name = self.binary_name_with_version(node_name, version);
311        let dest_path = self.cache_dir.join(&binary_name);
312
313        tokio::fs::copy(&extracted_binary, &dest_path).await?;
314
315        // Make executable
316        #[cfg(unix)]
317        {
318            use std::os::unix::fs::PermissionsExt;
319            let mut perms = tokio::fs::metadata(&dest_path).await?.permissions();
320            perms.set_mode(0o755);
321            tokio::fs::set_permissions(&dest_path, perms).await?;
322        }
323
324        // Create/update symlink
325        let symlink_path = self.cache_dir.join(node_name);
326        if symlink_path.exists() || symlink_path.is_symlink() {
327            tokio::fs::remove_file(&symlink_path).await.ok();
328        }
329
330        #[cfg(unix)]
331        {
332            std::os::unix::fs::symlink(&binary_name, &symlink_path)?;
333        }
334
335        #[cfg(windows)]
336        {
337            std::os::windows::fs::symlink_file(&dest_path, &symlink_path)?;
338        }
339
340        println!("✅ Installed {} v{} to {}", node_name, version, dest_path.display());
341
342        Ok(dest_path)
343    }
344
345    /// Fallback: install via cargo
346    async fn cargo_install(&self, node_name: &str) -> Result<PathBuf> {
347        println!(
348            "⚠️  Pre-built binary not available for {} on {}",
349            node_name, self.target
350        );
351        println!("🔨 Compiling via cargo install...");
352
353        let crate_name = format!("mecha10-nodes-{}", node_name);
354
355        let output = tokio::process::Command::new("cargo")
356            .args([
357                "install",
358                &crate_name,
359                "--version",
360                &self.version,
361                "--root",
362                &self.cache_dir.to_string_lossy(),
363            ])
364            .output()
365            .await
366            .context("Failed to run cargo install")?;
367
368        if !output.status.success() {
369            let stderr = String::from_utf8_lossy(&output.stderr);
370            return Err(anyhow::anyhow!("cargo install failed for {}: {}", crate_name, stderr));
371        }
372
373        // cargo install puts binary in {root}/bin/{name}
374        let binary_path = self.cache_dir.join("bin").join(node_name);
375        if !binary_path.exists() {
376            return Err(anyhow::anyhow!(
377                "Binary not found after cargo install: {}",
378                binary_path.display()
379            ));
380        }
381
382        println!("✅ Compiled {} via cargo install", node_name);
383
384        Ok(binary_path)
385    }
386
387    /// Build an HTTP client
388    fn build_client() -> Result<reqwest::Client> {
389        reqwest::Client::builder()
390            .user_agent("mecha10-cli")
391            .build()
392            .context("Failed to build HTTP client")
393    }
394
395    /// Remove a cached binary
396    pub async fn remove(&self, node_name: &str) -> Result<()> {
397        let binary_name = self.binary_name(node_name);
398        let binary_path = self.cache_dir.join(&binary_name);
399        let symlink_path = self.cache_dir.join(node_name);
400
401        if binary_path.exists() {
402            tokio::fs::remove_file(&binary_path).await?;
403            println!("Removed {}", binary_path.display());
404        }
405
406        if symlink_path.exists() || symlink_path.is_symlink() {
407            tokio::fs::remove_file(&symlink_path).await?;
408            println!("Removed symlink {}", symlink_path.display());
409        }
410
411        Ok(())
412    }
413
414    /// Clear all cached binaries
415    pub async fn clear_cache(&self) -> Result<()> {
416        if self.cache_dir.exists() {
417            tokio::fs::remove_dir_all(&self.cache_dir).await?;
418            println!("Cleared binary cache at {}", self.cache_dir.display());
419        }
420        Ok(())
421    }
422}
423
424impl Default for BinaryManager {
425    fn default() -> Self {
426        Self::new().expect("Failed to create BinaryManager")
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    // Naming/target-detection logic itself is covered by mecha10-binary-naming's own
435    // tests; these confirm BinaryManager's methods correctly wrap it with the
436    // struct's version/target.
437
438    #[test]
439    fn test_binary_name() {
440        let manager = BinaryManager {
441            cache_dir: PathBuf::from("/tmp"),
442            version: "0.1.44".to_string(),
443            target: "aarch64-apple-darwin".to_string(),
444        };
445
446        assert_eq!(manager.binary_name("speaker"), "speaker-0.1.44-aarch64-apple-darwin");
447    }
448
449    #[test]
450    fn test_tarball_name() {
451        let manager = BinaryManager {
452            cache_dir: PathBuf::from("/tmp"),
453            version: "0.1.44".to_string(),
454            target: "x86_64-apple-darwin".to_string(),
455        };
456
457        assert_eq!(manager.tarball_name("motor"), "motor-0.1.44-x86_64-apple-darwin.tar.gz");
458    }
459
460    #[test]
461    fn test_prebuilt_available() {
462        let manager = BinaryManager {
463            cache_dir: PathBuf::from("/tmp"),
464            version: "0.1.44".to_string(),
465            target: "aarch64-apple-darwin".to_string(),
466        };
467        assert!(manager.is_prebuilt_available());
468
469        // aarch64-unknown-linux-gnu is now supported (for Pi, Jetson with glibc)
470        let manager_arm_linux = BinaryManager {
471            cache_dir: PathBuf::from("/tmp"),
472            version: "0.1.44".to_string(),
473            target: "aarch64-unknown-linux-gnu".to_string(),
474        };
475        assert!(manager_arm_linux.is_prebuilt_available());
476
477        // musl variant is not supported (we use gnu for system library compatibility)
478        let manager_unsupported = BinaryManager {
479            cache_dir: PathBuf::from("/tmp"),
480            version: "0.1.44".to_string(),
481            target: "aarch64-unknown-linux-musl".to_string(),
482        };
483        assert!(!manager_unsupported.is_prebuilt_available());
484    }
485}