mecha10_deploy/services/
remote_image.rs1use crate::paths;
7use crate::types::project::TargetsConfig;
8use anyhow::{Context, Result};
9use serde::{Deserialize, Serialize};
10use std::path::Path;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct RemoteImageManifest {
15 pub version: String,
17 pub configurations: Vec<RemoteImageConfig>,
19 pub dockerfile_template_hash: String,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct RemoteImageConfig {
26 pub nodes_hash: String,
28 pub nodes: Vec<String>,
30 pub image_tag: String,
32 pub image_digest: Option<String>,
34}
35
36#[derive(Debug)]
38pub struct PrebuiltDetectionResult {
39 pub can_use_prebuilt: bool,
41 pub image_tag: Option<String>,
43 pub reason: Option<String>,
45}
46
47pub struct RemoteImageService;
49
50impl RemoteImageService {
51 pub fn new() -> Self {
53 Self
54 }
55
56 pub async fn detect_prebuilt(
60 &self,
61 targets: &TargetsConfig,
62 project_dir: &Path,
63 ) -> Result<PrebuiltDetectionResult> {
64 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 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 let dockerfile_path = project_dir.join("docker/Dockerfile.remote");
84 if dockerfile_path.exists() {
85 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 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 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 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 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 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 pub fn hash_nodes(&self, targets: &TargetsConfig) -> String {
156 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 hash.to_hex()[..16].to_string()
163 }
164
165 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 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 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}