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