use crate::types::project::TargetsConfig;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
const MANIFEST_URL: &str =
"https://raw.githubusercontent.com/mecha-industries/user-tools/main/mecha10-remote/manifest.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoteImageManifest {
pub version: String,
pub configurations: Vec<RemoteImageConfig>,
pub dockerfile_template_hash: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoteImageConfig {
pub nodes_hash: String,
pub nodes: Vec<String>,
pub image_tag: String,
pub image_digest: Option<String>,
}
#[derive(Debug)]
pub struct PrebuiltDetectionResult {
pub can_use_prebuilt: bool,
pub image_tag: Option<String>,
pub reason: Option<String>,
}
pub struct RemoteImageService;
impl RemoteImageService {
pub fn new() -> Self {
Self
}
pub async fn detect_prebuilt(
&self,
targets: &TargetsConfig,
project_dir: &Path,
) -> Result<PrebuiltDetectionResult> {
if targets.has_custom_remote_nodes() {
return Ok(PrebuiltDetectionResult {
can_use_prebuilt: false,
image_tag: None,
reason: Some("Project contains custom @local/* nodes".to_string()),
});
}
if !targets.has_remote_nodes() {
return Ok(PrebuiltDetectionResult {
can_use_prebuilt: false,
image_tag: None,
reason: Some("No remote nodes configured".to_string()),
});
}
let dockerfile_path = project_dir.join("docker/Dockerfile.remote");
if dockerfile_path.exists() {
match self.fetch_manifest().await {
Ok(manifest) => {
let local_hash = self.hash_file(&dockerfile_path).await?;
if local_hash != manifest.dockerfile_template_hash {
return Ok(PrebuiltDetectionResult {
can_use_prebuilt: false,
image_tag: None,
reason: Some("Dockerfile.remote has been modified".to_string()),
});
}
let nodes_hash = self.hash_nodes(targets);
for config in &manifest.configurations {
if config.nodes_hash == nodes_hash {
return Ok(PrebuiltDetectionResult {
can_use_prebuilt: true,
image_tag: Some(config.image_tag.clone()),
reason: None,
});
}
}
Ok(PrebuiltDetectionResult {
can_use_prebuilt: false,
image_tag: None,
reason: Some("No pre-built image for this node combination".to_string()),
})
}
Err(e) => {
Ok(PrebuiltDetectionResult {
can_use_prebuilt: false,
image_tag: None,
reason: Some(format!("Could not fetch manifest: {}", e)),
})
}
}
} else {
Ok(PrebuiltDetectionResult {
can_use_prebuilt: false,
image_tag: None,
reason: Some("Dockerfile.remote not found".to_string()),
})
}
}
pub async fn fetch_manifest(&self) -> Result<RemoteImageManifest> {
let response = reqwest::get(MANIFEST_URL)
.await
.context("Failed to fetch remote image manifest")?;
if !response.status().is_success() {
anyhow::bail!("Failed to fetch manifest: HTTP {}", response.status());
}
let manifest: RemoteImageManifest = response.json().await.context("Failed to parse remote image manifest")?;
Ok(manifest)
}
pub fn hash_nodes(&self, targets: &TargetsConfig) -> String {
let sorted_nodes = targets.sorted_remote_nodes();
let nodes_str = sorted_nodes.join(",");
let hash = blake3::hash(nodes_str.as_bytes());
hash.to_hex()[..16].to_string()
}
async fn hash_file(&self, path: &Path) -> Result<String> {
let content = tokio::fs::read(path).await.context("Failed to read file for hashing")?;
let hash = blake3::hash(&content);
Ok(hash.to_hex().to_string())
}
pub fn image_env_var() -> &'static str {
"MECHA10_REMOTE_IMAGE"
}
}
impl Default for RemoteImageService {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hash_nodes_deterministic() {
let service = RemoteImageService::new();
let targets1 = TargetsConfig {
remote: vec![
"@mecha10/object-detector".to_string(),
"@mecha10/image-classifier".to_string(),
],
..Default::default()
};
let targets2 = TargetsConfig {
remote: vec![
"@mecha10/image-classifier".to_string(),
"@mecha10/object-detector".to_string(),
],
..Default::default()
};
let hash1 = service.hash_nodes(&targets1);
let hash2 = service.hash_nodes(&targets2);
assert_eq!(hash1, hash2);
}
#[test]
fn test_hash_nodes_different_for_different_configs() {
let service = RemoteImageService::new();
let targets1 = TargetsConfig {
remote: vec!["@mecha10/object-detector".to_string()],
..Default::default()
};
let targets2 = TargetsConfig {
remote: vec!["@mecha10/image-classifier".to_string()],
..Default::default()
};
let hash1 = service.hash_nodes(&targets1);
let hash2 = service.hash_nodes(&targets2);
assert_ne!(hash1, hash2);
}
}