1use anyhow::{Context, Result};
16use serde::{Deserialize, Serialize};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum BuildJobState {
24 Pending,
25 Running,
26 Succeeded,
27 Failed,
28}
29
30#[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
54#[derive(Debug, Clone, Deserialize)]
59pub struct GithubConnection {
60 pub id: String,
61 #[serde(default)]
65 pub repo_full_name: Option<String>,
66}
67
68#[derive(Debug, Clone, Deserialize)]
71pub struct ListGithubConnectionsResponse {
72 pub connections: Vec<GithubConnection>,
73}
74
75#[derive(Debug, Clone, Deserialize)]
79pub struct StartInstallResponse {
80 pub install_url: String,
81}
82
83pub struct ReportBuildJobStatusParams<'a> {
89 pub control_plane_url: &'a str,
90 pub robot_id: &'a str,
91 pub job_id: &'a str,
92 pub service_token: &'a str,
93 pub state: BuildJobState,
94 pub binary_id: Option<&'a str>,
95 pub error_message: Option<&'a str>,
96}
97
98pub async fn report_build_job_status(params: ReportBuildJobStatusParams<'_>) -> Result<()> {
104 let ReportBuildJobStatusParams {
105 control_plane_url,
106 robot_id,
107 job_id,
108 service_token,
109 state,
110 binary_id,
111 error_message,
112 } = params;
113
114 let mut body = serde_json::json!({ "state": state });
115 if let Some(binary_id) = binary_id {
116 body["binary_id"] = serde_json::Value::String(binary_id.to_string());
117 }
118 if let Some(error_message) = error_message {
119 body["error_message"] = serde_json::Value::String(error_message.to_string());
120 }
121
122 let url = format!("{}/{}/status", build_jobs_url(control_plane_url, robot_id), job_id);
123
124 let client = reqwest::Client::new();
125 let response = client
126 .post(url)
127 .bearer_auth(service_token)
128 .json(&body)
129 .send()
130 .await
131 .context("Failed to send build-job status report")?;
132
133 let status = response.status();
134 let body = response
135 .text()
136 .await
137 .context("Failed to read build-job status report response")?;
138
139 if !status.is_success() {
140 anyhow::bail!("Failed to report build job status (status {status}): {body}");
141 }
142
143 Ok(())
144}
145
146pub struct CreateBuildJobFromGithubParams<'a> {
150 pub control_plane_url: &'a str,
151 pub robot_id: &'a str,
152 pub connection_id: &'a str,
153 pub target_platform: &'a str,
154 pub target_arch: &'a str,
155 pub config: Option<&'a str>,
156 pub api_key: &'a str,
157}
158
159fn build_jobs_url(control_plane_url: &str, robot_id: &str) -> String {
163 format!(
164 "{}/api/robots/{}/build-jobs",
165 control_plane_url.trim_end_matches('/'),
166 robot_id
167 )
168}
169
170fn github_connections_url(control_plane_url: &str) -> String {
173 format!("{}/api/github/connections", control_plane_url.trim_end_matches('/'))
174}
175
176fn github_install_start_url(control_plane_url: &str) -> String {
178 format!("{}/api/github/install/start", control_plane_url.trim_end_matches('/'))
179}
180
181pub async fn get_build_job(control_plane_url: &str, robot_id: &str, id: &str, api_key: &str) -> Result<BuildJob> {
184 let url = format!("{}/{}", build_jobs_url(control_plane_url, robot_id), id);
185
186 let client = reqwest::Client::new();
187 let response = client
188 .get(url)
189 .bearer_auth(api_key)
190 .send()
191 .await
192 .context("Failed to send build-job poll request")?;
193
194 let status = response.status();
195 let body = response
196 .text()
197 .await
198 .context("Failed to read build-job poll response")?;
199
200 if !status.is_success() {
201 anyhow::bail!("Failed to poll build job (status {status}): {body}");
202 }
203
204 serde_json::from_str(&body).context("Failed to parse build-job poll response")
205}
206
207pub async fn list_github_connections(control_plane_url: &str, api_key: &str) -> Result<Vec<GithubConnection>> {
211 let client = reqwest::Client::new();
212 let response = client
213 .get(github_connections_url(control_plane_url))
214 .bearer_auth(api_key)
215 .send()
216 .await
217 .context("Failed to send GitHub connections request")?;
218
219 let status = response.status();
220 let body = response
221 .text()
222 .await
223 .context("Failed to read GitHub connections response")?;
224
225 if !status.is_success() {
226 anyhow::bail!("Failed to list GitHub connections (status {status}): {body}");
227 }
228
229 let parsed: ListGithubConnectionsResponse =
230 serde_json::from_str(&body).context("Failed to parse GitHub connections response")?;
231 Ok(parsed.connections)
232}
233
234pub async fn start_github_install(control_plane_url: &str, api_key: &str) -> Result<StartInstallResponse> {
237 let client = reqwest::Client::new();
238 let response = client
239 .post(github_install_start_url(control_plane_url))
240 .bearer_auth(api_key)
241 .send()
242 .await
243 .context("Failed to send GitHub install start request")?;
244
245 let status = response.status();
246 let body = response
247 .text()
248 .await
249 .context("Failed to read GitHub install start response")?;
250
251 if !status.is_success() {
252 anyhow::bail!("Failed to start GitHub App install (status {status}): {body}");
253 }
254
255 serde_json::from_str(&body).context("Failed to parse GitHub install start response")
256}
257
258pub async fn create_build_job_from_github(
263 params: CreateBuildJobFromGithubParams<'_>,
264) -> Result<CreateBuildJobResponse> {
265 let CreateBuildJobFromGithubParams {
266 control_plane_url,
267 robot_id,
268 connection_id,
269 target_platform,
270 target_arch,
271 config,
272 api_key,
273 } = params;
274
275 let mut body = serde_json::json!({
276 "connection_id": connection_id,
277 "target_platform": target_platform,
278 "target_arch": target_arch,
279 });
280 if let Some(config) = config {
281 body["config"] = serde_json::Value::String(config.to_string());
282 }
283
284 let client = reqwest::Client::new();
285 let response = client
286 .post(format!("{}/from-github", build_jobs_url(control_plane_url, robot_id)))
287 .bearer_auth(api_key)
288 .json(&body)
289 .send()
290 .await
291 .context("Failed to send build-job-from-GitHub create request")?;
292
293 let status = response.status();
294 let body = response
295 .text()
296 .await
297 .context("Failed to read build-job-from-GitHub create response")?;
298
299 if !status.is_success() {
300 anyhow::bail!("Failed to create build job from GitHub (status {status}): {body}");
301 }
302
303 serde_json::from_str(&body).context("Failed to parse build-job-from-GitHub create response")
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309 use std::io::{Read, Write};
310
311 #[test]
312 fn build_job_state_serializes_to_snake_case_matching_the_server_contract() {
313 assert_eq!(serde_json::to_string(&BuildJobState::Pending).unwrap(), "\"pending\"");
314 assert_eq!(serde_json::to_string(&BuildJobState::Running).unwrap(), "\"running\"");
315 assert_eq!(
316 serde_json::to_string(&BuildJobState::Succeeded).unwrap(),
317 "\"succeeded\""
318 );
319 assert_eq!(serde_json::to_string(&BuildJobState::Failed).unwrap(), "\"failed\"");
320 }
321
322 #[test]
323 fn build_jobs_url_joins_control_plane_url_and_robot_id() {
324 assert_eq!(
325 build_jobs_url("https://api.mecha10.dev", "robot-1"),
326 "https://api.mecha10.dev/api/robots/robot-1/build-jobs"
327 );
328 }
329
330 #[test]
331 fn build_jobs_url_trims_a_trailing_slash_on_the_control_plane_url() {
332 assert_eq!(
333 build_jobs_url("https://api.mecha10.dev/", "robot-1"),
334 "https://api.mecha10.dev/api/robots/robot-1/build-jobs"
335 );
336 }
337
338 fn start_fake_server(status_line: &'static str, response_body: String) -> String {
343 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
344 let addr = listener.local_addr().unwrap();
345
346 std::thread::spawn(move || {
347 for stream in listener.incoming() {
348 let mut stream = match stream {
349 Ok(s) => s,
350 Err(_) => continue,
351 };
352 let _ = stream.set_read_timeout(Some(std::time::Duration::from_millis(200)));
357 let mut buf = [0u8; 65536];
358 loop {
359 match stream.read(&mut buf) {
360 Ok(0) => break,
361 Ok(_) => continue,
362 Err(ref e)
363 if e.kind() == std::io::ErrorKind::WouldBlock
364 || e.kind() == std::io::ErrorKind::TimedOut =>
365 {
366 break
367 }
368 Err(_) => break,
369 }
370 }
371 let header = format!(
372 "{status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
373 response_body.len()
374 );
375 let _ = stream.write_all(header.as_bytes());
376 let _ = stream.write_all(response_body.as_bytes());
377 let _ = stream.flush();
378 }
379 });
380
381 format!("http://{}", addr)
382 }
383
384 #[tokio::test]
385 async fn get_build_job_returns_the_parsed_response_on_success() {
386 let body = serde_json::json!({
387 "id": "job-1",
388 "state": "running",
389 "error_message": null,
390 "progress_message": "compiling"
391 })
392 .to_string();
393 let url = start_fake_server("HTTP/1.1 200 OK", body);
394
395 let result = get_build_job(&url, "robot-1", "job-1", "mecha_test_key").await.unwrap();
396
397 assert_eq!(result.state, BuildJobState::Running);
398 assert_eq!(result.progress_message, Some("compiling".to_string()));
399 }
400
401 #[tokio::test]
402 async fn get_build_job_surfaces_the_servers_error_on_failure() {
403 let url = start_fake_server(
404 "HTTP/1.1 404 Not Found",
405 serde_json::json!({"error": "build job job-1 not found"}).to_string(),
406 );
407
408 let err = get_build_job(&url, "robot-1", "job-1", "mecha_test_key")
409 .await
410 .unwrap_err();
411
412 assert!(err.to_string().contains("not found"));
413 }
414
415 #[test]
416 fn github_connections_url_joins_control_plane_url() {
417 assert_eq!(
418 github_connections_url("https://api.mecha10.dev"),
419 "https://api.mecha10.dev/api/github/connections"
420 );
421 assert_eq!(
422 github_connections_url("https://api.mecha10.dev/"),
423 "https://api.mecha10.dev/api/github/connections"
424 );
425 }
426
427 #[test]
428 fn github_install_start_url_joins_control_plane_url() {
429 assert_eq!(
430 github_install_start_url("https://api.mecha10.dev"),
431 "https://api.mecha10.dev/api/github/install/start"
432 );
433 }
434
435 #[tokio::test]
436 async fn list_github_connections_returns_the_parsed_connections_on_success() {
437 let body = serde_json::json!({
438 "connections": [
439 {
440 "id": "conn-1",
441 "installation_id": 42,
442 "account_login": "mecha-industries",
443 "repo_full_name": "mecha-industries/mecha10",
444 "created_at": "2024-01-01T00:00:00Z",
445 "updated_at": "2024-01-01T00:00:00Z"
446 },
447 {
448 "id": "conn-2",
449 "installation_id": 43,
450 "account_login": "someone-else",
451 "repo_full_name": null,
452 "created_at": "2024-01-01T00:00:00Z",
453 "updated_at": "2024-01-01T00:00:00Z"
454 }
455 ],
456 "total": 2
457 })
458 .to_string();
459 let url = start_fake_server("HTTP/1.1 200 OK", body);
460
461 let connections = list_github_connections(&url, "mecha_test_key").await.unwrap();
462
463 assert_eq!(connections.len(), 2);
464 assert_eq!(connections[0].id, "conn-1");
465 assert_eq!(
466 connections[0].repo_full_name,
467 Some("mecha-industries/mecha10".to_string())
468 );
469 assert_eq!(connections[1].repo_full_name, None);
470 }
471
472 #[tokio::test]
473 async fn list_github_connections_surfaces_the_servers_error_on_failure() {
474 let url = start_fake_server(
475 "HTTP/1.1 401 Unauthorized",
476 serde_json::json!({"error": "invalid API key"}).to_string(),
477 );
478
479 let err = list_github_connections(&url, "mecha_bad_key").await.unwrap_err();
480
481 assert!(err.to_string().contains("invalid API key"));
482 }
483
484 #[tokio::test]
485 async fn start_github_install_returns_the_install_url_on_success() {
486 let body = serde_json::json!({"install_url": "https://github.com/apps/mecha10/installations/new?state=abc"})
487 .to_string();
488 let url = start_fake_server("HTTP/1.1 200 OK", body);
489
490 let result = start_github_install(&url, "mecha_test_key").await.unwrap();
491
492 assert_eq!(
493 result.install_url,
494 "https://github.com/apps/mecha10/installations/new?state=abc"
495 );
496 }
497
498 #[tokio::test]
499 async fn start_github_install_surfaces_the_servers_error_on_failure() {
500 let url = start_fake_server(
501 "HTTP/1.1 500 Internal Server Error",
502 serde_json::json!({"error": "GitHub App is not configured"}).to_string(),
503 );
504
505 let err = start_github_install(&url, "mecha_test_key").await.unwrap_err();
506
507 assert!(err.to_string().contains("not configured"));
508 }
509
510 #[tokio::test]
511 async fn create_build_job_from_github_returns_the_parsed_response_on_success() {
512 let body = serde_json::json!({
513 "id": "job-1",
514 "robot_id": "robot-1",
515 "state": "pending",
516 "kestra_execution_id": null,
517 "message": "Build job queued"
518 })
519 .to_string();
520 let url = start_fake_server("HTTP/1.1 200 OK", body);
521
522 let result = create_build_job_from_github(CreateBuildJobFromGithubParams {
523 control_plane_url: &url,
524 robot_id: "robot-1",
525 connection_id: "conn-1",
526 target_platform: "linux",
527 target_arch: "aarch64",
528 config: None,
529 api_key: "mecha_test_key",
530 })
531 .await
532 .unwrap();
533
534 assert_eq!(result.id, "job-1");
535 }
536
537 #[tokio::test]
538 async fn create_build_job_from_github_surfaces_the_servers_error_on_failure() {
539 let url = start_fake_server(
540 "HTTP/1.1 400 Bad Request",
541 serde_json::json!({"error": "GitHub connection conn-1 has no repo selected yet"}).to_string(),
542 );
543
544 let err = create_build_job_from_github(CreateBuildJobFromGithubParams {
545 control_plane_url: &url,
546 robot_id: "robot-1",
547 connection_id: "conn-1",
548 target_platform: "linux",
549 target_arch: "aarch64",
550 config: None,
551 api_key: "mecha_test_key",
552 })
553 .await
554 .unwrap_err();
555
556 assert!(err.to_string().contains("no repo selected"));
557 }
558
559 #[tokio::test]
560 async fn report_build_job_status_succeeds_on_a_success_response() {
561 let url = start_fake_server("HTTP/1.1 200 OK", serde_json::json!({"ok": true}).to_string());
562
563 report_build_job_status(ReportBuildJobStatusParams {
564 control_plane_url: &url,
565 robot_id: "robot-1",
566 job_id: "job-1",
567 service_token: "service-token",
568 state: BuildJobState::Succeeded,
569 binary_id: Some("binary-1"),
570 error_message: None,
571 })
572 .await
573 .unwrap();
574 }
575
576 #[tokio::test]
577 async fn report_build_job_status_surfaces_the_servers_error_on_failure() {
578 let url = start_fake_server(
579 "HTTP/1.1 401 Unauthorized",
580 serde_json::json!({"error": "invalid service token"}).to_string(),
581 );
582
583 let err = report_build_job_status(ReportBuildJobStatusParams {
584 control_plane_url: &url,
585 robot_id: "robot-1",
586 job_id: "job-1",
587 service_token: "bad-token",
588 state: BuildJobState::Failed,
589 binary_id: None,
590 error_message: Some("build failed: cargo exited with status 101"),
591 })
592 .await
593 .unwrap_err();
594
595 assert!(err.to_string().contains("invalid service token"));
596 }
597
598 #[test]
599 fn report_build_job_status_url_matches_the_build_jobs_route_plus_status_suffix() {
600 let expected = format!("{}/job-1/status", build_jobs_url("https://api.mecha10.dev", "robot-1"));
602 assert_eq!(
603 expected,
604 "https://api.mecha10.dev/api/robots/robot-1/build-jobs/job-1/status"
605 );
606 }
607}