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, Some(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 the latest published binary for a node via mecha10-api.
226    ///
227    /// Deliberately does NOT request `version=<Self::version>` (this CLI's own framework
228    /// version) - node binaries are versioned independently by `mecha10-nodes` (see that
229    /// repo's README "Versioning" section), not guaranteed to equal this crate's
230    /// `CARGO_PKG_VERSION`. Omitting `version` lets `mecha10-api` resolve "latest" itself
231    /// (`binaries/latest.txt`), matching the one other caller that already needs this
232    /// (`resolve_cli_binary`'s "no override" case) rather than guessing a version client-side
233    /// that may not match what was actually published.
234    ///
235    /// Confirmed live (2026-10-15): assuming node version == framework version made every
236    /// single node binary 404 the moment the two repos' versions drifted (this crate at
237    /// `0.7.0`, `mecha10-nodes` still at `0.6.5`) - fixed both by automating the version
238    /// lockstep CI-side (mecha10-nodes PR ci/bump-version-on-mecha10-release) and by removing
239    /// this client-side assumption entirely.
240    async fn download(&self, node_name: &str) -> Result<PathBuf> {
241        self.download_version(node_name, None).await
242    }
243
244    /// Download a node binary via `mecha10-api`. `version`: `Some` pins an exact version
245    /// (`resolve_with_version`'s explicit-version tier); `None` asks mecha10-api for whatever
246    /// is latest (see [`Self::download`]'s doc comment for why that's the right default).
247    /// Either way, the version actually downloaded is read back from the response's
248    /// `Content-Disposition` filename rather than assumed - that's the version used to name
249    /// the cached file, so a `None` download caches under the real resolved version, not a
250    /// guess.
251    async fn download_version(&self, node_name: &str, version: Option<&str>) -> Result<PathBuf> {
252        let (os, arch) = mecha10_binary_naming::os_arch_query(&self.target)?;
253        let mut url = format!(
254            "{}/downloads/node/{}?os={}&arch={}",
255            paths::urls::mecha10_api_base_url(),
256            node_name,
257            os,
258            arch
259        );
260        if let Some(v) = version {
261            url.push_str(&format!("&version={}", v));
262        }
263
264        println!(
265            "📦 Downloading {} {}...",
266            node_name,
267            version.map(|v| format!("v{v}")).unwrap_or_else(|| "latest".to_string())
268        );
269        tracing::info!("Downloading binary from mecha10-api: {}", url);
270
271        let client = build_client()?;
272        let response = client
273            .get(&url)
274            .send()
275            .await
276            .context(format!("Failed to connect to mecha10-api for binary '{}'", node_name))?;
277
278        if !response.status().is_success() {
279            return Err(anyhow::anyhow!(
280                "Binary '{}' not found: HTTP {}\n\
281                 URL: {}",
282                node_name,
283                response.status(),
284                url
285            ));
286        }
287
288        let resolved_version = version
289            .map(|v| v.to_string())
290            .or_else(|| self.parse_resolved_version(node_name, &response))
291            .ok_or_else(|| {
292                anyhow::anyhow!(
293                    "mecha10-api did not report which version it resolved for '{}' \
294                     (missing/unparseable Content-Disposition header)",
295                    node_name
296                )
297            })?;
298
299        let size = response.content_length().unwrap_or(0);
300        self.download_and_extract_versioned(node_name, &resolved_version, response, size)
301            .await
302    }
303
304    /// Extracts the actual resolved version out of a download response's
305    /// `Content-Disposition` filename (`"{node_name}-{version}-{self.target}.tar.gz"` - see
306    /// `mecha10-api`'s `node_key`), for the `version: None` ("give me latest") case where the
307    /// caller doesn't already know what version it's getting.
308    fn parse_resolved_version(&self, node_name: &str, response: &reqwest::Response) -> Option<String> {
309        let header = response
310            .headers()
311            .get(reqwest::header::CONTENT_DISPOSITION)?
312            .to_str()
313            .ok()?;
314        let filename = header.split("filename=\"").nth(1)?.strip_suffix('"')?;
315        filename
316            .strip_prefix(&format!("{node_name}-"))?
317            .strip_suffix(&format!("-{}.tar.gz", self.target))
318            .map(str::to_string)
319    }
320
321    /// Download and extract a binary tarball with a specific version
322    async fn download_and_extract_versioned(
323        &self,
324        node_name: &str,
325        version: &str,
326        response: reqwest::Response,
327        size: u64,
328    ) -> Result<PathBuf> {
329        let pb = ProgressBar::new(size);
330        pb.set_style(
331            ProgressStyle::default_bar()
332                .template("{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
333                .unwrap()
334                .progress_chars("#>-"),
335        );
336
337        let temp_dir = tempfile::tempdir()?;
338        let temp_file = temp_dir.path().join("binary.tar.gz");
339
340        let mut file = tokio::fs::File::create(&temp_file).await?;
341        let mut stream = response.bytes_stream();
342
343        use futures_util::StreamExt;
344        use tokio::io::AsyncWriteExt;
345
346        while let Some(chunk) = stream.next().await {
347            let chunk = chunk.context("Error downloading chunk")?;
348            file.write_all(&chunk).await?;
349            pb.inc(chunk.len() as u64);
350        }
351
352        file.flush().await?;
353        pb.finish_with_message("Download complete");
354
355        tokio::fs::create_dir_all(&self.cache_dir).await?;
356
357        let tar_gz = std::fs::File::open(&temp_file)?;
358        let tar = flate2::read::GzDecoder::new(tar_gz);
359        let mut archive = tar::Archive::new(tar);
360
361        let extract_dir = temp_dir.path().join("extract");
362        std::fs::create_dir_all(&extract_dir)?;
363        archive.unpack(&extract_dir)?;
364
365        let extracted_binary = extract_dir.join(node_name);
366        if !extracted_binary.exists() {
367            return Err(anyhow::anyhow!("Binary '{}' not found in tarball", node_name));
368        }
369
370        let binary_name = self.binary_name_with_version(node_name, version);
371        let dest_path = self.cache_dir.join(&binary_name);
372
373        tokio::fs::copy(&extracted_binary, &dest_path).await?;
374
375        #[cfg(unix)]
376        {
377            use std::os::unix::fs::PermissionsExt;
378            let mut perms = tokio::fs::metadata(&dest_path).await?.permissions();
379            perms.set_mode(0o755);
380            tokio::fs::set_permissions(&dest_path, perms).await?;
381        }
382
383        let symlink_path = self.cache_dir.join(node_name);
384        if symlink_path.exists() || symlink_path.is_symlink() {
385            tokio::fs::remove_file(&symlink_path).await.ok();
386        }
387
388        #[cfg(unix)]
389        {
390            std::os::unix::fs::symlink(&binary_name, &symlink_path)?;
391        }
392
393        #[cfg(windows)]
394        {
395            std::os::windows::fs::symlink_file(&dest_path, &symlink_path)?;
396        }
397
398        println!("✅ Installed {} v{} to {}", node_name, version, dest_path.display());
399
400        Ok(dest_path)
401    }
402
403    /// Remove a cached binary
404    pub async fn remove(&self, node_name: &str) -> Result<()> {
405        let binary_name = self.binary_name(node_name);
406        let binary_path = self.cache_dir.join(&binary_name);
407        let symlink_path = self.cache_dir.join(node_name);
408
409        if binary_path.exists() {
410            tokio::fs::remove_file(&binary_path).await?;
411            println!("Removed {}", binary_path.display());
412        }
413
414        if symlink_path.exists() || symlink_path.is_symlink() {
415            tokio::fs::remove_file(&symlink_path).await?;
416            println!("Removed symlink {}", symlink_path.display());
417        }
418
419        Ok(())
420    }
421
422    /// Clear all cached binaries
423    pub async fn clear_cache(&self) -> Result<()> {
424        if self.cache_dir.exists() {
425            tokio::fs::remove_dir_all(&self.cache_dir).await?;
426            println!("Cleared binary cache at {}", self.cache_dir.display());
427        }
428        Ok(())
429    }
430}
431
432impl Default for BinaryManager {
433    fn default() -> Self {
434        Self::new().expect("Failed to create BinaryManager")
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    #[test]
443    fn test_binary_name() {
444        let manager = BinaryManager {
445            cache_dir: PathBuf::from("/tmp"),
446            version: "0.1.44".to_string(),
447            target: "aarch64-apple-darwin".to_string(),
448        };
449
450        assert_eq!(manager.binary_name("speaker"), "speaker-0.1.44-aarch64-apple-darwin");
451    }
452
453    #[test]
454    fn test_tarball_name() {
455        let manager = BinaryManager {
456            cache_dir: PathBuf::from("/tmp"),
457            version: "0.1.44".to_string(),
458            target: "x86_64-apple-darwin".to_string(),
459        };
460
461        assert_eq!(manager.tarball_name("motor"), "motor-0.1.44-x86_64-apple-darwin.tar.gz");
462    }
463
464    #[tokio::test]
465    async fn resolve_errors_immediately_on_unsupported_platform_with_no_cargo_install_fallback() {
466        let manager = BinaryManager {
467            cache_dir: PathBuf::from("/tmp/mecha10-binary-manager-test-does-not-exist"),
468            version: "0.1.44".to_string(),
469            target: "aarch64-unknown-linux-musl".to_string(),
470        };
471
472        let err = manager
473            .resolve("speaker")
474            .await
475            .expect_err("unsupported platform must error, not silently fall back");
476
477        let message = err.to_string();
478        assert!(
479            message.contains("No pre-built binary available"),
480            "error should name the missing source, got: {message}"
481        );
482        assert!(
483            message.contains("no cargo-install or build-from-source fallback"),
484            "error must make clear there is no fallback path, got: {message}"
485        );
486    }
487
488    #[test]
489    fn test_prebuilt_available() {
490        let manager = BinaryManager {
491            cache_dir: PathBuf::from("/tmp"),
492            version: "0.1.44".to_string(),
493            target: "aarch64-apple-darwin".to_string(),
494        };
495        assert!(manager.is_prebuilt_available());
496
497        let manager_arm_linux = BinaryManager {
498            cache_dir: PathBuf::from("/tmp"),
499            version: "0.1.44".to_string(),
500            target: "aarch64-unknown-linux-gnu".to_string(),
501        };
502        assert!(manager_arm_linux.is_prebuilt_available());
503
504        let manager_unsupported = BinaryManager {
505            cache_dir: PathBuf::from("/tmp"),
506            version: "0.1.44".to_string(),
507            target: "aarch64-unknown-linux-musl".to_string(),
508        };
509        assert!(!manager_unsupported.is_prebuilt_available());
510    }
511}