mecha10_cli/services/
remote_image.rs

1//! Remote image detection service
2//!
3//! Handles detection of whether to use pre-built images from the registry
4//! or build locally based on project configuration.
5
6use crate::types::project::TargetsConfig;
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9use std::path::Path;
10
11/// URL for the remote image manifest hosted in user-tools
12const MANIFEST_URL: &str = "https://raw.githubusercontent.com/laboratory-one/user-tools/main/mecha10-remote/manifest.json";
13
14/// Manifest describing available pre-built remote images
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct RemoteImageManifest {
17    /// Version of the manifest schema
18    pub version: String,
19    /// Available pre-built configurations
20    pub configurations: Vec<RemoteImageConfig>,
21    /// Hash of the template Dockerfile.remote
22    pub dockerfile_template_hash: String,
23}
24
25/// A pre-built image configuration
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct RemoteImageConfig {
28    /// Hash of the sorted node array (for matching)
29    pub nodes_hash: String,
30    /// List of nodes included in this image
31    pub nodes: Vec<String>,
32    /// Docker image tag to pull
33    pub image_tag: String,
34    /// Image digest for verification
35    pub image_digest: Option<String>,
36}
37
38/// Result of pre-built image detection
39#[derive(Debug)]
40pub struct PrebuiltDetectionResult {
41    /// Whether a pre-built image can be used
42    pub can_use_prebuilt: bool,
43    /// The image tag to use (if pre-built available)
44    pub image_tag: Option<String>,
45    /// Reason why pre-built cannot be used
46    pub reason: Option<String>,
47}
48
49/// Service for detecting and managing remote images
50pub struct RemoteImageService;
51
52impl RemoteImageService {
53    /// Create a new remote image service
54    pub fn new() -> Self {
55        Self
56    }
57
58    /// Detect whether a pre-built image can be used
59    ///
60    /// Returns information about whether to use pre-built or build locally.
61    pub async fn detect_prebuilt(
62        &self,
63        targets: &TargetsConfig,
64        project_dir: &Path,
65    ) -> Result<PrebuiltDetectionResult> {
66        // Check for custom nodes first (quick check, no network needed)
67        if targets.has_custom_remote_nodes() {
68            return Ok(PrebuiltDetectionResult {
69                can_use_prebuilt: false,
70                image_tag: None,
71                reason: Some("Project contains custom @local/* nodes".to_string()),
72            });
73        }
74
75        // Check if there are any remote nodes
76        if !targets.has_remote_nodes() {
77            return Ok(PrebuiltDetectionResult {
78                can_use_prebuilt: false,
79                image_tag: None,
80                reason: Some("No remote nodes configured".to_string()),
81            });
82        }
83
84        // Check if Dockerfile.remote has been modified
85        let dockerfile_path = project_dir.join("docker/Dockerfile.remote");
86        if dockerfile_path.exists() {
87            // Try to fetch manifest and compare dockerfile hash
88            match self.fetch_manifest().await {
89                Ok(manifest) => {
90                    let local_hash = self.hash_file(&dockerfile_path).await?;
91                    if local_hash != manifest.dockerfile_template_hash {
92                        return Ok(PrebuiltDetectionResult {
93                            can_use_prebuilt: false,
94                            image_tag: None,
95                            reason: Some("Dockerfile.remote has been modified".to_string()),
96                        });
97                    }
98
99                    // Check if we have a matching configuration
100                    let nodes_hash = self.hash_nodes(targets);
101                    for config in &manifest.configurations {
102                        if config.nodes_hash == nodes_hash {
103                            return Ok(PrebuiltDetectionResult {
104                                can_use_prebuilt: true,
105                                image_tag: Some(config.image_tag.clone()),
106                                reason: None,
107                            });
108                        }
109                    }
110
111                    // No matching configuration found
112                    Ok(PrebuiltDetectionResult {
113                        can_use_prebuilt: false,
114                        image_tag: None,
115                        reason: Some("No pre-built image for this node combination".to_string()),
116                    })
117                }
118                Err(e) => {
119                    // Network error or manifest not available
120                    Ok(PrebuiltDetectionResult {
121                        can_use_prebuilt: false,
122                        image_tag: None,
123                        reason: Some(format!("Could not fetch manifest: {}", e)),
124                    })
125                }
126            }
127        } else {
128            // No Dockerfile.remote, can't use prebuilt
129            Ok(PrebuiltDetectionResult {
130                can_use_prebuilt: false,
131                image_tag: None,
132                reason: Some("Dockerfile.remote not found".to_string()),
133            })
134        }
135    }
136
137    /// Fetch the remote image manifest from user-tools
138    pub async fn fetch_manifest(&self) -> Result<RemoteImageManifest> {
139        let response = reqwest::get(MANIFEST_URL)
140            .await
141            .context("Failed to fetch remote image manifest")?;
142
143        if !response.status().is_success() {
144            anyhow::bail!(
145                "Failed to fetch manifest: HTTP {}",
146                response.status()
147            );
148        }
149
150        let manifest: RemoteImageManifest = response
151            .json()
152            .await
153            .context("Failed to parse remote image manifest")?;
154
155        Ok(manifest)
156    }
157
158    /// Compute a hash of the remote nodes for matching against manifest
159    pub fn hash_nodes(&self, targets: &TargetsConfig) -> String {
160        // Get sorted nodes for deterministic hashing
161        let sorted_nodes = targets.sorted_remote_nodes();
162        let nodes_str = sorted_nodes.join(",");
163
164        let hash = blake3::hash(nodes_str.as_bytes());
165        // Return first 16 hex chars for brevity
166        hash.to_hex()[..16].to_string()
167    }
168
169    /// Hash a file's contents
170    async fn hash_file(&self, path: &Path) -> Result<String> {
171        let content = tokio::fs::read(path)
172            .await
173            .context("Failed to read file for hashing")?;
174
175        let hash = blake3::hash(&content);
176        Ok(hash.to_hex().to_string())
177    }
178
179    /// Get the image environment variable name
180    pub fn image_env_var() -> &'static str {
181        "MECHA10_REMOTE_IMAGE"
182    }
183}
184
185impl Default for RemoteImageService {
186    fn default() -> Self {
187        Self::new()
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn test_hash_nodes_deterministic() {
197        let service = RemoteImageService::new();
198
199        let mut targets1 = TargetsConfig::default();
200        targets1.remote = vec![
201            "@mecha10/object-detector".to_string(),
202            "@mecha10/image-classifier".to_string(),
203        ];
204
205        let mut targets2 = TargetsConfig::default();
206        targets2.remote = vec![
207            "@mecha10/image-classifier".to_string(),
208            "@mecha10/object-detector".to_string(),
209        ];
210
211        // Should produce same hash regardless of order
212        let hash1 = service.hash_nodes(&targets1);
213        let hash2 = service.hash_nodes(&targets2);
214        assert_eq!(hash1, hash2);
215    }
216
217    #[test]
218    fn test_hash_nodes_different_for_different_configs() {
219        let service = RemoteImageService::new();
220
221        let mut targets1 = TargetsConfig::default();
222        targets1.remote = vec!["@mecha10/object-detector".to_string()];
223
224        let mut targets2 = TargetsConfig::default();
225        targets2.remote = vec!["@mecha10/image-classifier".to_string()];
226
227        let hash1 = service.hash_nodes(&targets1);
228        let hash2 = service.hash_nodes(&targets2);
229        assert_ne!(hash1, hash2);
230    }
231}