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 =
13 "https://raw.githubusercontent.com/mecha-industries/user-tools/main/mecha10-remote/manifest.json";
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct RemoteImageManifest {
18 pub version: String,
20 pub configurations: Vec<RemoteImageConfig>,
22 pub dockerfile_template_hash: String,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct RemoteImageConfig {
29 pub nodes_hash: String,
31 pub nodes: Vec<String>,
33 pub image_tag: String,
35 pub image_digest: Option<String>,
37}
38
39#[derive(Debug)]
41pub struct PrebuiltDetectionResult {
42 pub can_use_prebuilt: bool,
44 pub image_tag: Option<String>,
46 pub reason: Option<String>,
48}
49
50pub struct RemoteImageService;
52
53impl RemoteImageService {
54 pub fn new() -> Self {
56 Self
57 }
58
59 pub async fn detect_prebuilt(
63 &self,
64 targets: &TargetsConfig,
65 project_dir: &Path,
66 ) -> Result<PrebuiltDetectionResult> {
67 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 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 let dockerfile_path = project_dir.join("docker/Dockerfile.remote");
87 if dockerfile_path.exists() {
88 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 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 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 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 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 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 pub fn hash_nodes(&self, targets: &TargetsConfig) -> String {
155 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 hash.to_hex()[..16].to_string()
162 }
163
164 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 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 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}