Skip to main content

mecha10_cli/services/
build_job_client.rs

1//! HTTP client for mecha10-api's build-jobs endpoint (LAB-5443), used by the CLI's
2//! remote/dashboard build flow (LAB-5442, part of "build via mecha10 dashboard", LAB-2153).
3//!
4//! Mirrors the request/response shapes pinned in mecha10-api's `jobs::build_jobs` module
5//! (`services/api/src/jobs/build_jobs.rs`) and implemented by
6//! `services/api/src/robot/build_jobs/handlers.rs`: a multipart `POST
7//! /api/robots/{robot_id}/build-jobs` (the `.mecha10ignore`-filtered zip under
8//! `build_source::BUILD_SOURCE_UPLOAD_FIELD`, plus `target_platform`/`target_arch`/optional
9//! `config` sidecar fields) and a `GET /api/robots/{robot_id}/build-jobs/{id}` poll endpoint.
10//! Auth is the caller's own long-lived `mecha_...` API key as a bearer token on both calls -
11//! no new auth mechanism (`jobs::auth::AuthUser`'s `FromRequestParts` impl only looks at the
12//! `Authorization: Bearer <token>` header, same as every other authenticated CLI command).
13
14use 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/// Build-job lifecycle state - matches mecha10-api's `BuildJobState` wire format exactly
20/// (`#[serde(rename_all = "snake_case")]`, no `cancelled` variant - a build job, like an L6
21/// dataset build, has no cancel route).
22#[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/// `POST /api/robots/{robot_id}/build-jobs` response body - matches mecha10-api's
32/// `CreateBuildJobResponse`. Only `id` is modeled: it's the only field the caller reads (to
33/// report the created job's id and to start polling it); `state`/`message` describe the
34/// freshly-created job's initial status, which the poll loop immediately re-fetches anyway.
35#[derive(Debug, Clone, Deserialize)]
36pub struct CreateBuildJobResponse {
37    pub id: String,
38}
39
40/// `GET /api/robots/{robot_id}/build-jobs/{id}` response body - matches mecha10-api's
41/// `BuildJob`/`GetBuildJobResponse` shape. Only the fields the CLI's poll loop surfaces are
42/// modeled here; `id`/`binary_id`/timestamps/etc. aren't needed for v0 (the poll loop already
43/// has the job id from `create_build_job`, and just reports success/failure with a progress
44/// line, not the resulting binary's registry id).
45#[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/// Inputs to [`create_build_job`], grouped into one struct rather than a long positional
55/// argument list (same convention as `handlers/upload.rs`'s `UploadParams`).
56pub 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
66/// Shared URL builder for both the create and poll endpoints - one place that knows the
67/// route shape (`/api/robots/{robot_id}/build-jobs[...]`), matching mecha10-api's
68/// `robot::build_jobs::router()`.
69fn 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
77/// `POST /api/robots/{robot_id}/build-jobs`: upload the already-constructed, already
78/// size-checked project source zip plus the build's sidecar fields, and return the created
79/// job. The caller's own long-lived API key is sent as a bearer token - the same credential
80/// every other authenticated CLI command already uses, not a new per-job token.
81pub 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
133/// `GET /api/robots/{robot_id}/build-jobs/{id}`: fetch the current state of a build job -
134/// the CLI's poll loop calls this on an interval until `state` is terminal.
135pub 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    /// Minimal fake HTTP server that always responds `response_body` with `status_line` to
193    /// any request - same pattern as `handlers/build.rs`'s `FakeAssetsServer`, trimmed down
194    /// since these tests don't need to inspect the request at all (the server-side multipart
195    /// parsing is mecha10-api's responsibility, pinned by its own tests, not this repo's).
196    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}