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