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//! Covers the GitHub-based remote build path (LAB-5450, mecha10-api's
5//! `integrations::github_app` module and `robot::build_jobs::handlers::
6//! create_build_job_from_github`): `POST /api/github/install/start` (mint the GitHub App
7//! install URL), `GET /api/github/connections` (check whether the caller already has a
8//! connected repo), `POST /api/robots/{robot_id}/build-jobs/from-github` (dispatch a build
9//! that clones the connected repo server-side - a JSON body, no multipart upload at all, since
10//! there's no zip), and a `GET /api/robots/{robot_id}/build-jobs/{id}` poll endpoint. Auth is
11//! the caller's own long-lived `mecha_...` API key as a bearer token on every call - no new
12//! auth mechanism (`jobs::auth::AuthUser`'s `FromRequestParts` impl only looks at the
13//! `Authorization: Bearer <token>` header, same as every other authenticated CLI command).
14
15use anyhow::{Context, Result};
16use serde::{Deserialize, Serialize};
17
18/// Build-job lifecycle state - matches mecha10-api's `BuildJobState` wire format exactly
19/// (`#[serde(rename_all = "snake_case")]`, no `cancelled` variant - a build job, like an L6
20/// dataset build, has no cancel route).
21#[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/// `POST /api/robots/{robot_id}/build-jobs[/from-github]` response body - matches
31/// mecha10-api's `CreateBuildJobResponse`. Only `id` is modeled: it's the only field the
32/// caller reads (to report the created job's id and to start polling it); `state`/`message`
33/// describe the freshly-created job's initial status, which the poll loop immediately
34/// 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_from_github`, and just reports success/failure with
44/// a progress 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/// A connected GitHub App installation (`GET /api/github/connections` row) - matches
55/// mecha10-api's `integrations::github_app::types::GithubConnectionSummary`. Only the fields
56/// the CLI actually reads are modeled; `created_at`/`updated_at` aren't needed to decide
57/// whether a usable connection exists or to dispatch a build from one.
58#[derive(Debug, Clone, Deserialize)]
59pub struct GithubConnection {
60    pub id: String,
61    /// `None` until a repo is selected (via the dashboard's `PUT /api/github/connections/{id}`
62    /// - the CLI doesn't drive that step itself) - a connection with no repo selected isn't
63    ///   usable for `create_build_job_from_github` yet.
64    #[serde(default)]
65    pub repo_full_name: Option<String>,
66}
67
68/// `GET /api/github/connections` response body - matches mecha10-api's
69/// `ListGithubConnectionsResponse`.
70#[derive(Debug, Clone, Deserialize)]
71pub struct ListGithubConnectionsResponse {
72    pub connections: Vec<GithubConnection>,
73}
74
75/// `POST /api/github/install/start` response body - matches mecha10-api's
76/// `StartInstallResponse`. Just GitHub's own installation URL (with a signed `state` already
77/// attached server-side) for the CLI to print for the user to open in a browser.
78#[derive(Debug, Clone, Deserialize)]
79pub struct StartInstallResponse {
80    pub install_url: String,
81}
82
83/// Inputs to [`report_build_job_status`] - the call the build-worker itself makes (LAB-5440),
84/// as opposed to every other function in this module which is called by the CLI running on a
85/// developer's own machine. Auth is therefore a service token (mecha10-api's
86/// `AppState::control_plane_service_key`, handed to the worker via the Kestra `token` input/
87/// `MECHA10_API_KEY` env var), not the caller's own long-lived `mecha_...` API key.
88pub 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
98/// `POST /api/robots/{robot_id}/build-jobs/{job_id}/status`: the build-worker's own report of
99/// its terminal outcome (LAB-5440/5443) - this is what actually moves a build-job row out of
100/// `pending`/`running` into `succeeded`/`failed`, since nothing else observes the dispatched
101/// Kestra container's exit. Bearer-auths with the control-plane service token, not a user's
102/// API key - see [`ReportBuildJobStatusParams`].
103pub 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
146/// Inputs to [`create_build_job_from_github`] - there is nothing to upload (the dispatched
147/// build container clones `connection_id`'s repo itself), so this just carries the build's
148/// sidecar fields plus `connection_id` identifying which connected GitHub repo to build from.
149pub 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
159/// Shared URL builder for both the create and poll endpoints - one place that knows the
160/// route shape (`/api/robots/{robot_id}/build-jobs[...]`), matching mecha10-api's
161/// `robot::build_jobs::router()`.
162fn 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
170/// `GET /api/github/connections` URL - matches mecha10-api's `integrations::github_app`
171/// router.
172fn github_connections_url(control_plane_url: &str) -> String {
173    format!("{}/api/github/connections", control_plane_url.trim_end_matches('/'))
174}
175
176/// `POST /api/github/install/start` URL.
177fn github_install_start_url(control_plane_url: &str) -> String {
178    format!("{}/api/github/install/start", control_plane_url.trim_end_matches('/'))
179}
180
181/// `GET /api/robots/{robot_id}/build-jobs/{id}`: fetch the current state of a build job -
182/// the CLI's poll loop calls this on an interval until `state` is terminal.
183pub 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
207/// `GET /api/github/connections`: every GitHub App installation the caller has connected -
208/// used by the remote/dashboard build flow to decide whether a build can go through the
209/// GitHub-clone path (LAB-5450) without ever constructing or uploading a zip.
210pub 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
234/// `POST /api/github/install/start`: mint the GitHub App installation URL the CLI prints for
235/// the user to open in a browser to connect a repo.
236pub 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
258/// `POST /api/robots/{robot_id}/build-jobs/from-github`: dispatch a build job that clones
259/// `connection_id`'s connected repo server-side (LAB-5450) - a JSON body, not multipart, since
260/// there is no file part at all. Returns `CreateBuildJobResponse` and is followed by the same
261/// poll loop/endpoint as every other build job.
262pub 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    /// Minimal fake HTTP server that always responds `response_body` with `status_line` to
339    /// any request - same pattern as `handlers/build.rs`'s `FakeAssetsServer`, trimmed down
340    /// since these tests don't need to inspect the request at all (the server-side multipart
341    /// parsing is mecha10-api's responsibility, pinned by its own tests, not this repo's).
342    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                // A single `read` can return before the client finishes writing the full
353                // request - TCP gives no message-boundary guarantee, and under CI scheduling
354                // jitter the write can be split across multiple segments. Read until the
355                // stream goes quiet instead of assuming one `read` captures everything.
356                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        // Pure string-shape check (no network): `.../build-jobs/{job_id}/status`.
601        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}