Skip to main content

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