1use crate::services::build_source::{BUILD_SOURCE_UPLOAD_CONTENT_TYPE, BUILD_SOURCE_UPLOAD_FIELD};
15use anyhow::{Context, Result};
16use serde::{Deserialize, Serialize};
17use std::path::Path;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum BuildJobState {
25 Pending,
26 Running,
27 Succeeded,
28 Failed,
29}
30
31#[derive(Debug, Clone, Deserialize)]
36pub struct CreateBuildJobResponse {
37 pub id: String,
38}
39
40#[derive(Debug, Clone, Deserialize)]
46pub struct BuildJob {
47 pub state: BuildJobState,
48 #[serde(default)]
49 pub error_message: Option<String>,
50 #[serde(default)]
51 pub progress_message: Option<String>,
52}
53
54pub struct CreateBuildJobParams<'a> {
57 pub control_plane_url: &'a str,
58 pub robot_id: &'a str,
59 pub target_platform: &'a str,
60 pub target_arch: &'a str,
61 pub config: Option<&'a str>,
62 pub api_key: &'a str,
63 pub zip_path: &'a Path,
64}
65
66fn build_jobs_url(control_plane_url: &str, robot_id: &str) -> String {
70 format!(
71 "{}/api/robots/{}/build-jobs",
72 control_plane_url.trim_end_matches('/'),
73 robot_id
74 )
75}
76
77pub async fn create_build_job(params: CreateBuildJobParams<'_>) -> Result<CreateBuildJobResponse> {
82 let CreateBuildJobParams {
83 control_plane_url,
84 robot_id,
85 target_platform,
86 target_arch,
87 config,
88 api_key,
89 zip_path,
90 } = params;
91
92 let zip_bytes = tokio::fs::read(zip_path)
93 .await
94 .with_context(|| format!("Failed to read {}", zip_path.display()))?;
95
96 let file_part = reqwest::multipart::Part::bytes(zip_bytes)
97 .file_name("source.zip".to_string())
98 .mime_str(BUILD_SOURCE_UPLOAD_CONTENT_TYPE)
99 .context("Failed to set source zip content type")?;
100
101 let mut form = reqwest::multipart::Form::new()
102 .text("robot_id", robot_id.to_string())
103 .text("target_platform", target_platform.to_string())
104 .text("target_arch", target_arch.to_string())
105 .part(BUILD_SOURCE_UPLOAD_FIELD, file_part);
106
107 if let Some(config) = config {
108 form = form.text("config", config.to_string());
109 }
110
111 let client = reqwest::Client::new();
112 let response = client
113 .post(build_jobs_url(control_plane_url, robot_id))
114 .bearer_auth(api_key)
115 .multipart(form)
116 .send()
117 .await
118 .context("Failed to send build-job create request")?;
119
120 let status = response.status();
121 let body = response
122 .text()
123 .await
124 .context("Failed to read build-job create response")?;
125
126 if !status.is_success() {
127 anyhow::bail!("Failed to create build job (status {status}): {body}");
128 }
129
130 serde_json::from_str(&body).context("Failed to parse build-job create response")
131}
132
133pub async fn get_build_job(control_plane_url: &str, robot_id: &str, id: &str, api_key: &str) -> Result<BuildJob> {
136 let url = format!("{}/{}", build_jobs_url(control_plane_url, robot_id), id);
137
138 let client = reqwest::Client::new();
139 let response = client
140 .get(url)
141 .bearer_auth(api_key)
142 .send()
143 .await
144 .context("Failed to send build-job poll request")?;
145
146 let status = response.status();
147 let body = response
148 .text()
149 .await
150 .context("Failed to read build-job poll response")?;
151
152 if !status.is_success() {
153 anyhow::bail!("Failed to poll build job (status {status}): {body}");
154 }
155
156 serde_json::from_str(&body).context("Failed to parse build-job poll response")
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162 use std::io::{Read, Write};
163 use tempfile::tempdir;
164
165 #[test]
166 fn build_job_state_serializes_to_snake_case_matching_the_server_contract() {
167 assert_eq!(serde_json::to_string(&BuildJobState::Pending).unwrap(), "\"pending\"");
168 assert_eq!(serde_json::to_string(&BuildJobState::Running).unwrap(), "\"running\"");
169 assert_eq!(
170 serde_json::to_string(&BuildJobState::Succeeded).unwrap(),
171 "\"succeeded\""
172 );
173 assert_eq!(serde_json::to_string(&BuildJobState::Failed).unwrap(), "\"failed\"");
174 }
175
176 #[test]
177 fn build_jobs_url_joins_control_plane_url_and_robot_id() {
178 assert_eq!(
179 build_jobs_url("https://api.mecha10.dev", "robot-1"),
180 "https://api.mecha10.dev/api/robots/robot-1/build-jobs"
181 );
182 }
183
184 #[test]
185 fn build_jobs_url_trims_a_trailing_slash_on_the_control_plane_url() {
186 assert_eq!(
187 build_jobs_url("https://api.mecha10.dev/", "robot-1"),
188 "https://api.mecha10.dev/api/robots/robot-1/build-jobs"
189 );
190 }
191
192 fn start_fake_server(status_line: &'static str, response_body: String) -> String {
197 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
198 let addr = listener.local_addr().unwrap();
199
200 std::thread::spawn(move || {
201 for stream in listener.incoming() {
202 let mut stream = match stream {
203 Ok(s) => s,
204 Err(_) => continue,
205 };
206 let mut buf = [0u8; 65536];
207 let _ = stream.read(&mut buf);
208 let header = format!(
209 "{status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
210 response_body.len()
211 );
212 let _ = stream.write_all(header.as_bytes());
213 let _ = stream.write_all(response_body.as_bytes());
214 let _ = stream.flush();
215 }
216 });
217
218 format!("http://{}", addr)
219 }
220
221 #[tokio::test]
222 async fn create_build_job_returns_the_parsed_response_on_success() {
223 let body = serde_json::json!({"id": "job-1", "robot_id": "robot-1", "state": "pending", "message": "Build job queued"}).to_string();
224 let url = start_fake_server("HTTP/1.1 200 OK", body);
225
226 let dir = tempdir().unwrap();
227 let zip_path = dir.path().join("source.zip");
228 std::fs::write(&zip_path, b"fake zip bytes").unwrap();
229
230 let result = create_build_job(CreateBuildJobParams {
231 control_plane_url: &url,
232 robot_id: "robot-1",
233 target_platform: "linux",
234 target_arch: "aarch64",
235 config: None,
236 api_key: "mecha_test_key",
237 zip_path: &zip_path,
238 })
239 .await
240 .unwrap();
241
242 assert_eq!(result.id, "job-1");
243 }
244
245 #[tokio::test]
246 async fn create_build_job_surfaces_the_servers_error_body_on_failure() {
247 let url = start_fake_server(
248 "HTTP/1.1 400 Bad Request",
249 serde_json::json!({"error": "'source' exceeds the 1073741824-byte limit"}).to_string(),
250 );
251
252 let dir = tempdir().unwrap();
253 let zip_path = dir.path().join("source.zip");
254 std::fs::write(&zip_path, b"fake zip bytes").unwrap();
255
256 let err = create_build_job(CreateBuildJobParams {
257 control_plane_url: &url,
258 robot_id: "robot-1",
259 target_platform: "linux",
260 target_arch: "aarch64",
261 config: None,
262 api_key: "mecha_test_key",
263 zip_path: &zip_path,
264 })
265 .await
266 .unwrap_err();
267
268 assert!(err.to_string().contains("exceeds the"));
269 }
270
271 #[tokio::test]
272 async fn get_build_job_returns_the_parsed_response_on_success() {
273 let body = serde_json::json!({
274 "id": "job-1",
275 "state": "running",
276 "error_message": null,
277 "progress_message": "compiling"
278 })
279 .to_string();
280 let url = start_fake_server("HTTP/1.1 200 OK", body);
281
282 let result = get_build_job(&url, "robot-1", "job-1", "mecha_test_key").await.unwrap();
283
284 assert_eq!(result.state, BuildJobState::Running);
285 assert_eq!(result.progress_message, Some("compiling".to_string()));
286 }
287
288 #[tokio::test]
289 async fn get_build_job_surfaces_the_servers_error_on_failure() {
290 let url = start_fake_server(
291 "HTTP/1.1 404 Not Found",
292 serde_json::json!({"error": "build job job-1 not found"}).to_string(),
293 );
294
295 let err = get_build_job(&url, "robot-1", "job-1", "mecha_test_key")
296 .await
297 .unwrap_err();
298
299 assert!(err.to_string().contains("not found"));
300 }
301}