mecha10_cli/services/
remote_image.rs1use crate::types::project::TargetsConfig;
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9use std::path::Path;
10
11const MANIFEST_URL: &str = "https://raw.githubusercontent.com/laboratory-one/user-tools/main/mecha10-remote/manifest.json";
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct RemoteImageManifest {
17 pub version: String,
19 pub configurations: Vec<RemoteImageConfig>,
21 pub dockerfile_template_hash: String,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct RemoteImageConfig {
28 pub nodes_hash: String,
30 pub nodes: Vec<String>,
32 pub image_tag: String,
34 pub image_digest: Option<String>,
36}
37
38#[derive(Debug)]
40pub struct PrebuiltDetectionResult {
41 pub can_use_prebuilt: bool,
43 pub image_tag: Option<String>,
45 pub reason: Option<String>,
47}
48
49pub struct RemoteImageService;
51
52impl RemoteImageService {
53 pub fn new() -> Self {
55 Self
56 }
57
58 pub async fn detect_prebuilt(
62 &self,
63 targets: &TargetsConfig,
64 project_dir: &Path,
65 ) -> Result<PrebuiltDetectionResult> {
66 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 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 let dockerfile_path = project_dir.join("docker/Dockerfile.remote");
86 if dockerfile_path.exists() {
87 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 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 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 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 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 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 pub fn hash_nodes(&self, targets: &TargetsConfig) -> String {
160 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 hash.to_hex()[..16].to_string()
167 }
168
169 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 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 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}