use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
#[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>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GithubConnection {
pub id: String,
#[serde(default)]
pub repo_full_name: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ListGithubConnectionsResponse {
pub connections: Vec<GithubConnection>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct StartInstallResponse {
pub install_url: String,
}
pub struct ReportBuildJobStatusParams<'a> {
pub control_plane_url: &'a str,
pub robot_id: &'a str,
pub job_id: &'a str,
pub service_token: &'a str,
pub state: BuildJobState,
pub binary_id: Option<&'a str>,
pub error_message: Option<&'a str>,
}
pub async fn report_build_job_status(params: ReportBuildJobStatusParams<'_>) -> Result<()> {
let ReportBuildJobStatusParams {
control_plane_url,
robot_id,
job_id,
service_token,
state,
binary_id,
error_message,
} = params;
let mut body = serde_json::json!({ "state": state });
if let Some(binary_id) = binary_id {
body["binary_id"] = serde_json::Value::String(binary_id.to_string());
}
if let Some(error_message) = error_message {
body["error_message"] = serde_json::Value::String(error_message.to_string());
}
let url = format!("{}/{}/status", build_jobs_url(control_plane_url, robot_id), job_id);
let client = reqwest::Client::new();
let response = client
.post(url)
.bearer_auth(service_token)
.json(&body)
.send()
.await
.context("Failed to send build-job status report")?;
let status = response.status();
let body = response
.text()
.await
.context("Failed to read build-job status report response")?;
if !status.is_success() {
anyhow::bail!("Failed to report build job status (status {status}): {body}");
}
Ok(())
}
pub struct CreateBuildJobFromGithubParams<'a> {
pub control_plane_url: &'a str,
pub robot_id: &'a str,
pub connection_id: &'a str,
pub target_platform: &'a str,
pub target_arch: &'a str,
pub config: Option<&'a str>,
pub api_key: &'a str,
}
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
)
}
fn github_connections_url(control_plane_url: &str) -> String {
format!("{}/api/github/connections", control_plane_url.trim_end_matches('/'))
}
fn github_install_start_url(control_plane_url: &str) -> String {
format!("{}/api/github/install/start", control_plane_url.trim_end_matches('/'))
}
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")
}
pub async fn list_github_connections(control_plane_url: &str, api_key: &str) -> Result<Vec<GithubConnection>> {
let client = reqwest::Client::new();
let response = client
.get(github_connections_url(control_plane_url))
.bearer_auth(api_key)
.send()
.await
.context("Failed to send GitHub connections request")?;
let status = response.status();
let body = response
.text()
.await
.context("Failed to read GitHub connections response")?;
if !status.is_success() {
anyhow::bail!("Failed to list GitHub connections (status {status}): {body}");
}
let parsed: ListGithubConnectionsResponse =
serde_json::from_str(&body).context("Failed to parse GitHub connections response")?;
Ok(parsed.connections)
}
pub async fn start_github_install(control_plane_url: &str, api_key: &str) -> Result<StartInstallResponse> {
let client = reqwest::Client::new();
let response = client
.post(github_install_start_url(control_plane_url))
.bearer_auth(api_key)
.send()
.await
.context("Failed to send GitHub install start request")?;
let status = response.status();
let body = response
.text()
.await
.context("Failed to read GitHub install start response")?;
if !status.is_success() {
anyhow::bail!("Failed to start GitHub App install (status {status}): {body}");
}
serde_json::from_str(&body).context("Failed to parse GitHub install start response")
}
pub async fn create_build_job_from_github(
params: CreateBuildJobFromGithubParams<'_>,
) -> Result<CreateBuildJobResponse> {
let CreateBuildJobFromGithubParams {
control_plane_url,
robot_id,
connection_id,
target_platform,
target_arch,
config,
api_key,
} = params;
let mut body = serde_json::json!({
"connection_id": connection_id,
"target_platform": target_platform,
"target_arch": target_arch,
});
if let Some(config) = config {
body["config"] = serde_json::Value::String(config.to_string());
}
let client = reqwest::Client::new();
let response = client
.post(format!("{}/from-github", build_jobs_url(control_plane_url, robot_id)))
.bearer_auth(api_key)
.json(&body)
.send()
.await
.context("Failed to send build-job-from-GitHub create request")?;
let status = response.status();
let body = response
.text()
.await
.context("Failed to read build-job-from-GitHub create response")?;
if !status.is_success() {
anyhow::bail!("Failed to create build job from GitHub (status {status}): {body}");
}
serde_json::from_str(&body).context("Failed to parse build-job-from-GitHub create response")
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
#[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 _ = stream.set_read_timeout(Some(std::time::Duration::from_millis(200)));
let mut buf = [0u8; 65536];
loop {
match stream.read(&mut buf) {
Ok(0) => break,
Ok(_) => continue,
Err(ref e)
if e.kind() == std::io::ErrorKind::WouldBlock
|| e.kind() == std::io::ErrorKind::TimedOut =>
{
break
}
Err(_) => break,
}
}
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 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"));
}
#[test]
fn github_connections_url_joins_control_plane_url() {
assert_eq!(
github_connections_url("https://api.mecha10.dev"),
"https://api.mecha10.dev/api/github/connections"
);
assert_eq!(
github_connections_url("https://api.mecha10.dev/"),
"https://api.mecha10.dev/api/github/connections"
);
}
#[test]
fn github_install_start_url_joins_control_plane_url() {
assert_eq!(
github_install_start_url("https://api.mecha10.dev"),
"https://api.mecha10.dev/api/github/install/start"
);
}
#[tokio::test]
async fn list_github_connections_returns_the_parsed_connections_on_success() {
let body = serde_json::json!({
"connections": [
{
"id": "conn-1",
"installation_id": 42,
"account_login": "mecha-industries",
"repo_full_name": "mecha-industries/mecha10",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
},
{
"id": "conn-2",
"installation_id": 43,
"account_login": "someone-else",
"repo_full_name": null,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
],
"total": 2
})
.to_string();
let url = start_fake_server("HTTP/1.1 200 OK", body);
let connections = list_github_connections(&url, "mecha_test_key").await.unwrap();
assert_eq!(connections.len(), 2);
assert_eq!(connections[0].id, "conn-1");
assert_eq!(
connections[0].repo_full_name,
Some("mecha-industries/mecha10".to_string())
);
assert_eq!(connections[1].repo_full_name, None);
}
#[tokio::test]
async fn list_github_connections_surfaces_the_servers_error_on_failure() {
let url = start_fake_server(
"HTTP/1.1 401 Unauthorized",
serde_json::json!({"error": "invalid API key"}).to_string(),
);
let err = list_github_connections(&url, "mecha_bad_key").await.unwrap_err();
assert!(err.to_string().contains("invalid API key"));
}
#[tokio::test]
async fn start_github_install_returns_the_install_url_on_success() {
let body = serde_json::json!({"install_url": "https://github.com/apps/mecha10/installations/new?state=abc"})
.to_string();
let url = start_fake_server("HTTP/1.1 200 OK", body);
let result = start_github_install(&url, "mecha_test_key").await.unwrap();
assert_eq!(
result.install_url,
"https://github.com/apps/mecha10/installations/new?state=abc"
);
}
#[tokio::test]
async fn start_github_install_surfaces_the_servers_error_on_failure() {
let url = start_fake_server(
"HTTP/1.1 500 Internal Server Error",
serde_json::json!({"error": "GitHub App is not configured"}).to_string(),
);
let err = start_github_install(&url, "mecha_test_key").await.unwrap_err();
assert!(err.to_string().contains("not configured"));
}
#[tokio::test]
async fn create_build_job_from_github_returns_the_parsed_response_on_success() {
let body = serde_json::json!({
"id": "job-1",
"robot_id": "robot-1",
"state": "pending",
"kestra_execution_id": null,
"message": "Build job queued"
})
.to_string();
let url = start_fake_server("HTTP/1.1 200 OK", body);
let result = create_build_job_from_github(CreateBuildJobFromGithubParams {
control_plane_url: &url,
robot_id: "robot-1",
connection_id: "conn-1",
target_platform: "linux",
target_arch: "aarch64",
config: None,
api_key: "mecha_test_key",
})
.await
.unwrap();
assert_eq!(result.id, "job-1");
}
#[tokio::test]
async fn create_build_job_from_github_surfaces_the_servers_error_on_failure() {
let url = start_fake_server(
"HTTP/1.1 400 Bad Request",
serde_json::json!({"error": "GitHub connection conn-1 has no repo selected yet"}).to_string(),
);
let err = create_build_job_from_github(CreateBuildJobFromGithubParams {
control_plane_url: &url,
robot_id: "robot-1",
connection_id: "conn-1",
target_platform: "linux",
target_arch: "aarch64",
config: None,
api_key: "mecha_test_key",
})
.await
.unwrap_err();
assert!(err.to_string().contains("no repo selected"));
}
#[tokio::test]
async fn report_build_job_status_succeeds_on_a_success_response() {
let url = start_fake_server("HTTP/1.1 200 OK", serde_json::json!({"ok": true}).to_string());
report_build_job_status(ReportBuildJobStatusParams {
control_plane_url: &url,
robot_id: "robot-1",
job_id: "job-1",
service_token: "service-token",
state: BuildJobState::Succeeded,
binary_id: Some("binary-1"),
error_message: None,
})
.await
.unwrap();
}
#[tokio::test]
async fn report_build_job_status_surfaces_the_servers_error_on_failure() {
let url = start_fake_server(
"HTTP/1.1 401 Unauthorized",
serde_json::json!({"error": "invalid service token"}).to_string(),
);
let err = report_build_job_status(ReportBuildJobStatusParams {
control_plane_url: &url,
robot_id: "robot-1",
job_id: "job-1",
service_token: "bad-token",
state: BuildJobState::Failed,
binary_id: None,
error_message: Some("build failed: cargo exited with status 101"),
})
.await
.unwrap_err();
assert!(err.to_string().contains("invalid service token"));
}
#[test]
fn report_build_job_status_url_matches_the_build_jobs_route_plus_status_suffix() {
let expected = format!("{}/job-1/status", build_jobs_url("https://api.mecha10.dev", "robot-1"));
assert_eq!(
expected,
"https://api.mecha10.dev/api/robots/robot-1/build-jobs/job-1/status"
);
}
}