use crate::services::build_source::{BUILD_SOURCE_UPLOAD_CONTENT_TYPE, BUILD_SOURCE_UPLOAD_FIELD};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BuildJobState {
Pending,
Running,
Succeeded,
Failed,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CreateBuildJobResponse {
pub id: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct BuildJob {
pub state: BuildJobState,
#[serde(default)]
pub error_message: Option<String>,
#[serde(default)]
pub progress_message: Option<String>,
}
pub struct CreateBuildJobParams<'a> {
pub control_plane_url: &'a str,
pub robot_id: &'a str,
pub target_platform: &'a str,
pub target_arch: &'a str,
pub config: Option<&'a str>,
pub api_key: &'a str,
pub zip_path: &'a Path,
}
fn build_jobs_url(control_plane_url: &str, robot_id: &str) -> String {
format!(
"{}/api/robots/{}/build-jobs",
control_plane_url.trim_end_matches('/'),
robot_id
)
}
pub async fn create_build_job(params: CreateBuildJobParams<'_>) -> Result<CreateBuildJobResponse> {
let CreateBuildJobParams {
control_plane_url,
robot_id,
target_platform,
target_arch,
config,
api_key,
zip_path,
} = params;
let zip_bytes = tokio::fs::read(zip_path)
.await
.with_context(|| format!("Failed to read {}", zip_path.display()))?;
let file_part = reqwest::multipart::Part::bytes(zip_bytes)
.file_name("source.zip".to_string())
.mime_str(BUILD_SOURCE_UPLOAD_CONTENT_TYPE)
.context("Failed to set source zip content type")?;
let mut form = reqwest::multipart::Form::new()
.text("robot_id", robot_id.to_string())
.text("target_platform", target_platform.to_string())
.text("target_arch", target_arch.to_string())
.part(BUILD_SOURCE_UPLOAD_FIELD, file_part);
if let Some(config) = config {
form = form.text("config", config.to_string());
}
let client = reqwest::Client::new();
let response = client
.post(build_jobs_url(control_plane_url, robot_id))
.bearer_auth(api_key)
.multipart(form)
.send()
.await
.context("Failed to send build-job create request")?;
let status = response.status();
let body = response
.text()
.await
.context("Failed to read build-job create response")?;
if !status.is_success() {
anyhow::bail!("Failed to create build job (status {status}): {body}");
}
serde_json::from_str(&body).context("Failed to parse build-job create response")
}
pub async fn get_build_job(control_plane_url: &str, robot_id: &str, id: &str, api_key: &str) -> Result<BuildJob> {
let url = format!("{}/{}", build_jobs_url(control_plane_url, robot_id), id);
let client = reqwest::Client::new();
let response = client
.get(url)
.bearer_auth(api_key)
.send()
.await
.context("Failed to send build-job poll request")?;
let status = response.status();
let body = response
.text()
.await
.context("Failed to read build-job poll response")?;
if !status.is_success() {
anyhow::bail!("Failed to poll build job (status {status}): {body}");
}
serde_json::from_str(&body).context("Failed to parse build-job poll response")
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use tempfile::tempdir;
#[test]
fn build_job_state_serializes_to_snake_case_matching_the_server_contract() {
assert_eq!(serde_json::to_string(&BuildJobState::Pending).unwrap(), "\"pending\"");
assert_eq!(serde_json::to_string(&BuildJobState::Running).unwrap(), "\"running\"");
assert_eq!(
serde_json::to_string(&BuildJobState::Succeeded).unwrap(),
"\"succeeded\""
);
assert_eq!(serde_json::to_string(&BuildJobState::Failed).unwrap(), "\"failed\"");
}
#[test]
fn build_jobs_url_joins_control_plane_url_and_robot_id() {
assert_eq!(
build_jobs_url("https://api.mecha10.dev", "robot-1"),
"https://api.mecha10.dev/api/robots/robot-1/build-jobs"
);
}
#[test]
fn build_jobs_url_trims_a_trailing_slash_on_the_control_plane_url() {
assert_eq!(
build_jobs_url("https://api.mecha10.dev/", "robot-1"),
"https://api.mecha10.dev/api/robots/robot-1/build-jobs"
);
}
fn start_fake_server(status_line: &'static str, response_body: String) -> String {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
for stream in listener.incoming() {
let mut stream = match stream {
Ok(s) => s,
Err(_) => continue,
};
let mut buf = [0u8; 65536];
let _ = stream.read(&mut buf);
let header = format!(
"{status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
response_body.len()
);
let _ = stream.write_all(header.as_bytes());
let _ = stream.write_all(response_body.as_bytes());
let _ = stream.flush();
}
});
format!("http://{}", addr)
}
#[tokio::test]
async fn create_build_job_returns_the_parsed_response_on_success() {
let body = serde_json::json!({"id": "job-1", "robot_id": "robot-1", "state": "pending", "message": "Build job queued"}).to_string();
let url = start_fake_server("HTTP/1.1 200 OK", body);
let dir = tempdir().unwrap();
let zip_path = dir.path().join("source.zip");
std::fs::write(&zip_path, b"fake zip bytes").unwrap();
let result = create_build_job(CreateBuildJobParams {
control_plane_url: &url,
robot_id: "robot-1",
target_platform: "linux",
target_arch: "aarch64",
config: None,
api_key: "mecha_test_key",
zip_path: &zip_path,
})
.await
.unwrap();
assert_eq!(result.id, "job-1");
}
#[tokio::test]
async fn create_build_job_surfaces_the_servers_error_body_on_failure() {
let url = start_fake_server(
"HTTP/1.1 400 Bad Request",
serde_json::json!({"error": "'source' exceeds the 1073741824-byte limit"}).to_string(),
);
let dir = tempdir().unwrap();
let zip_path = dir.path().join("source.zip");
std::fs::write(&zip_path, b"fake zip bytes").unwrap();
let err = create_build_job(CreateBuildJobParams {
control_plane_url: &url,
robot_id: "robot-1",
target_platform: "linux",
target_arch: "aarch64",
config: None,
api_key: "mecha_test_key",
zip_path: &zip_path,
})
.await
.unwrap_err();
assert!(err.to_string().contains("exceeds the"));
}
#[tokio::test]
async fn get_build_job_returns_the_parsed_response_on_success() {
let body = serde_json::json!({
"id": "job-1",
"state": "running",
"error_message": null,
"progress_message": "compiling"
})
.to_string();
let url = start_fake_server("HTTP/1.1 200 OK", body);
let result = get_build_job(&url, "robot-1", "job-1", "mecha_test_key").await.unwrap();
assert_eq!(result.state, BuildJobState::Running);
assert_eq!(result.progress_message, Some("compiling".to_string()));
}
#[tokio::test]
async fn get_build_job_surfaces_the_servers_error_on_failure() {
let url = start_fake_server(
"HTTP/1.1 404 Not Found",
serde_json::json!({"error": "build job job-1 not found"}).to_string(),
);
let err = get_build_job(&url, "robot-1", "job-1", "mecha_test_key")
.await
.unwrap_err();
assert!(err.to_string().contains("not found"));
}
}