Skip to main content

ostool/board/
client.rs

1use std::fmt;
2
3use anyhow::Context as _;
4use chrono::{DateTime, Utc};
5use httpboot_protocol::KernelPublishResponse;
6use reqwest::{StatusCode, Url};
7use serde::{Deserialize, Serialize, de::DeserializeOwned};
8use thiserror::Error;
9
10#[derive(Debug, Clone)]
11pub struct BoardServerClient {
12    client: reqwest::Client,
13    base_url: Url,
14    ws_base_url: Url,
15}
16
17#[derive(Debug, Clone, Deserialize)]
18pub struct BoardTypeSummary {
19    pub board_type: String,
20    pub tags: Vec<String>,
21    pub total: usize,
22    pub available: usize,
23}
24
25#[derive(Debug, Clone, Serialize)]
26pub struct CreateSessionRequest {
27    pub board_type: String,
28    pub required_tags: Vec<String>,
29    pub client_name: Option<String>,
30}
31
32#[derive(Debug, Clone, Deserialize)]
33pub struct SessionCreatedResponse {
34    pub session_id: String,
35    pub board_id: String,
36    pub lease_expires_at: DateTime<Utc>,
37    pub serial_available: bool,
38    pub boot_mode: String,
39    pub ws_url: Option<String>,
40}
41
42#[derive(Debug, Clone, Deserialize)]
43pub struct HeartbeatResponse {
44    pub session_id: String,
45    pub lease_expires_at: DateTime<Utc>,
46}
47
48#[derive(Debug, Clone, Deserialize)]
49#[serde(tag = "kind", rename_all = "snake_case")]
50pub enum BootConfig {
51    Uboot(UbootProfile),
52    Pxe(PxeProfile),
53    #[serde(rename = "httpboot", alias = "uefi_http")]
54    UefiHttp(UefiHttpProfile),
55}
56
57#[derive(Debug, Clone, Deserialize, Default)]
58pub struct UbootProfile {
59    #[serde(default)]
60    pub use_tftp: bool,
61    pub dtb_name: Option<String>,
62    #[serde(default)]
63    pub kernel_load_addr: Option<String>,
64    #[serde(default)]
65    pub fit_load_addr: Option<String>,
66    #[serde(default)]
67    pub bootm_addr: Option<String>,
68    #[serde(default)]
69    pub network_mode: UbootNetworkMode,
70    #[serde(default)]
71    pub board_ip: Option<String>,
72    #[serde(default)]
73    pub server_ip: Option<String>,
74    #[serde(default)]
75    pub netmask: Option<String>,
76    #[serde(default)]
77    pub gatewayip: Option<String>,
78}
79
80#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]
81#[serde(rename_all = "snake_case")]
82pub enum UbootNetworkMode {
83    #[default]
84    Dhcp,
85    StaticIp,
86}
87
88#[derive(Debug, Clone, Deserialize, Default)]
89pub struct PxeProfile {
90    pub notes: Option<String>,
91}
92
93#[derive(Debug, Clone)]
94pub struct HttpBootKernelUpload {
95    pub remote_name: String,
96    pub arch: String,
97    pub image_format: String,
98    pub entry_symbol: Option<String>,
99    pub bytes: Vec<u8>,
100}
101
102#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
103#[serde(rename_all = "snake_case")]
104pub enum UefiBootArch {
105    X86_64,
106    Aarch64,
107    Loongarch64,
108    Riscv64,
109    Other,
110}
111
112#[derive(Debug, Clone, Deserialize)]
113pub struct UefiHttpProfile {
114    pub boot_arch: Option<UefiBootArch>,
115    pub mac: Option<String>,
116}
117
118#[derive(Debug, Clone, Deserialize)]
119pub struct BootProfileResponse {
120    pub boot: BootConfig,
121    pub server_ip: Option<String>,
122    pub netmask: Option<String>,
123    pub interface: Option<String>,
124}
125
126#[derive(Debug, Clone, Deserialize)]
127pub struct SerialStatusResponse {
128    pub available: bool,
129    pub connected: bool,
130    pub port: Option<String>,
131    pub baud_rate: Option<u32>,
132    pub ws_url: Option<String>,
133}
134
135#[derive(Debug, Clone, Deserialize)]
136pub struct FileResponse {
137    pub filename: String,
138    pub relative_path: String,
139    pub tftp_url: Option<String>,
140    pub size: u64,
141    pub uploaded_at: DateTime<Utc>,
142}
143
144#[derive(Debug, Clone, Deserialize)]
145pub struct HttpBootFileResponse {
146    pub filename: String,
147    pub relative_path: String,
148    pub http_url: String,
149    pub size: u64,
150    pub uploaded_at: DateTime<Utc>,
151}
152
153#[derive(Debug, Clone, Deserialize)]
154pub struct TftpSessionResponse {
155    pub available: bool,
156    pub provider: String,
157    pub server_ip: Option<String>,
158    pub netmask: Option<String>,
159    pub writable: bool,
160    pub files: Vec<FileResponse>,
161}
162
163#[derive(Debug, Clone, Deserialize)]
164pub struct SessionDtbResponse {
165    pub dtb_name: Option<String>,
166    pub relative_path: Option<String>,
167    pub session_file_path: Option<String>,
168    pub tftp_url: Option<String>,
169}
170
171#[derive(Debug, Clone, Deserialize)]
172struct ErrorResponse {
173    code: String,
174    message: String,
175}
176
177#[derive(Debug, Clone, Error)]
178#[error("{message}")]
179pub struct BoardServerClientError {
180    pub status: StatusCode,
181    pub code: Option<String>,
182    pub message: String,
183}
184
185impl BoardServerClientError {
186    pub fn is_no_available_board_for(&self, board_type: &str) -> bool {
187        self.status == StatusCode::CONFLICT
188            && self.code.as_deref() == Some("conflict")
189            && self.message == format!("no available board for type `{board_type}`")
190    }
191
192    pub fn is_board_type_not_found_for(&self, board_type: &str) -> bool {
193        self.status == StatusCode::NOT_FOUND
194            && self.code.as_deref() == Some("not_found")
195            && self.message == format!("board type `{board_type}` not found")
196    }
197}
198
199impl BoardServerClient {
200    pub fn new(server: &str, port: u16) -> anyhow::Result<Self> {
201        Ok(Self {
202            client: reqwest::Client::builder()
203                .no_proxy()
204                .build()
205                .context("failed to build HTTP client")?,
206            base_url: build_base_url("http", server, port)?,
207            ws_base_url: build_base_url("ws", server, port)?,
208        })
209    }
210
211    pub async fn list_board_types(&self) -> Result<Vec<BoardTypeSummary>, BoardServerClientError> {
212        let response = self
213            .client
214            .get(self.endpoint("/api/v1/board-types"))
215            .send()
216            .await
217            .map_err(Self::request_error)?;
218        self.decode_json(response).await
219    }
220
221    pub async fn create_session(
222        &self,
223        board_type: &str,
224    ) -> Result<SessionCreatedResponse, BoardServerClientError> {
225        let response = self
226            .client
227            .post(self.endpoint("/api/v1/sessions"))
228            .json(&CreateSessionRequest {
229                board_type: board_type.to_string(),
230                required_tags: vec![],
231                client_name: Some("ostool".to_string()),
232            })
233            .send()
234            .await
235            .map_err(Self::request_error)?;
236        self.decode_json(response).await
237    }
238
239    pub async fn heartbeat(
240        &self,
241        session_id: &str,
242    ) -> Result<HeartbeatResponse, BoardServerClientError> {
243        let response = self
244            .client
245            .post(self.endpoint(&format!("/api/v1/sessions/{session_id}/heartbeat")))
246            .send()
247            .await
248            .map_err(Self::request_error)?;
249        self.decode_json(response).await
250    }
251
252    pub async fn delete_session(&self, session_id: &str) -> Result<(), BoardServerClientError> {
253        let response = self
254            .client
255            .delete(self.endpoint(&format!("/api/v1/sessions/{session_id}")))
256            .send()
257            .await
258            .map_err(Self::request_error)?;
259        if response.status() == StatusCode::NOT_FOUND {
260            return Ok(());
261        }
262        self.decode_empty(response).await
263    }
264
265    pub async fn get_boot_profile(
266        &self,
267        session_id: &str,
268    ) -> Result<BootProfileResponse, BoardServerClientError> {
269        let response = self
270            .client
271            .get(self.endpoint(&format!("/api/v1/sessions/{session_id}/boot-profile")))
272            .send()
273            .await
274            .map_err(Self::request_error)?;
275        self.decode_json(response).await
276    }
277
278    pub async fn get_serial_status(
279        &self,
280        session_id: &str,
281    ) -> Result<SerialStatusResponse, BoardServerClientError> {
282        let response = self
283            .client
284            .get(self.endpoint(&format!("/api/v1/sessions/{session_id}/serial")))
285            .send()
286            .await
287            .map_err(Self::request_error)?;
288        self.decode_json(response).await
289    }
290
291    pub async fn get_tftp_status(
292        &self,
293        session_id: &str,
294    ) -> Result<TftpSessionResponse, BoardServerClientError> {
295        let response = self
296            .client
297            .get(self.endpoint(&format!("/api/v1/sessions/{session_id}/tftp")))
298            .send()
299            .await
300            .map_err(Self::request_error)?;
301        self.decode_json(response).await
302    }
303
304    pub async fn get_session_dtb(
305        &self,
306        session_id: &str,
307    ) -> Result<SessionDtbResponse, BoardServerClientError> {
308        let response = self
309            .client
310            .get(self.endpoint(&format!("/api/v1/sessions/{session_id}/dtb")))
311            .send()
312            .await
313            .map_err(Self::request_error)?;
314        self.decode_json(response).await
315    }
316
317    pub async fn download_session_dtb(
318        &self,
319        session_id: &str,
320    ) -> Result<Vec<u8>, BoardServerClientError> {
321        let response = self
322            .client
323            .get(self.endpoint(&format!("/api/v1/sessions/{session_id}/dtb/download")))
324            .send()
325            .await
326            .map_err(Self::request_error)?;
327        self.decode_bytes(response).await
328    }
329
330    pub async fn power_on_board(&self, session_id: &str) -> Result<(), BoardServerClientError> {
331        let response = self
332            .client
333            .post(self.endpoint(&format!("/api/v1/sessions/{session_id}/board/power-on")))
334            .send()
335            .await
336            .map_err(Self::request_error)?;
337        self.decode_empty(response).await
338    }
339
340    pub async fn power_off_board(&self, session_id: &str) -> Result<(), BoardServerClientError> {
341        let response = self
342            .client
343            .post(self.endpoint(&format!("/api/v1/sessions/{session_id}/board/power-off")))
344            .send()
345            .await
346            .map_err(Self::request_error)?;
347        self.decode_empty(response).await
348    }
349
350    pub async fn upload_session_file(
351        &self,
352        session_id: &str,
353        relative_path: &str,
354        bytes: Vec<u8>,
355    ) -> Result<FileResponse, BoardServerClientError> {
356        let response = self
357            .client
358            .put(self.endpoint(&format!("/api/v1/sessions/{session_id}/files")))
359            .header("X-File-Path", relative_path)
360            .body(bytes)
361            .send()
362            .await
363            .map_err(Self::request_error)?;
364        self.decode_json(response).await
365    }
366
367    pub async fn upload_http_boot_file(
368        &self,
369        session_id: &str,
370        relative_path: &str,
371        bytes: Vec<u8>,
372    ) -> Result<HttpBootFileResponse, BoardServerClientError> {
373        let response = self
374            .client
375            .put(self.endpoint(&format!("/api/v1/sessions/{session_id}/http-boot/files")))
376            .header("X-File-Path", relative_path)
377            .body(bytes)
378            .send()
379            .await
380            .map_err(Self::request_error)?;
381        self.decode_json(response).await
382    }
383
384    pub async fn upload_http_boot_kernel(
385        &self,
386        session_id: &str,
387        upload: HttpBootKernelUpload,
388    ) -> Result<KernelPublishResponse, BoardServerClientError> {
389        let mut request = self
390            .client
391            .put(self.endpoint(&format!("/api/v1/sessions/{session_id}/http-boot/kernel")))
392            .header("X-HttpBoot-Remote-Name", upload.remote_name)
393            .header("X-HttpBoot-Arch", upload.arch)
394            .header("X-HttpBoot-Image-Format", upload.image_format);
395        if let Some(entry_symbol) = upload.entry_symbol {
396            request = request.header("X-HttpBoot-Entry-Symbol", entry_symbol);
397        }
398        let response = request
399            .body(upload.bytes)
400            .send()
401            .await
402            .map_err(Self::request_error)?;
403        self.decode_json(response).await
404    }
405
406    pub fn resolve_ws_url(&self, ws_url: &str) -> anyhow::Result<Url> {
407        if ws_url.starts_with("ws://") || ws_url.starts_with("wss://") {
408            return Url::parse(ws_url).with_context(|| format!("invalid websocket URL `{ws_url}`"));
409        }
410
411        self.ws_base_url
412            .join(ws_url)
413            .with_context(|| format!("failed to resolve websocket URL `{ws_url}`"))
414    }
415
416    fn endpoint(&self, path: &str) -> Url {
417        self.base_url
418            .join(path.trim_start_matches('/'))
419            .expect("static API path should be valid")
420    }
421
422    async fn decode_json<T: DeserializeOwned>(
423        &self,
424        response: reqwest::Response,
425    ) -> Result<T, BoardServerClientError> {
426        if response.status().is_success() {
427            response.json::<T>().await.map_err(Self::request_error)
428        } else {
429            Err(Self::api_error(response).await)
430        }
431    }
432
433    async fn decode_empty(
434        &self,
435        response: reqwest::Response,
436    ) -> Result<(), BoardServerClientError> {
437        if response.status().is_success() {
438            Ok(())
439        } else {
440            Err(Self::api_error(response).await)
441        }
442    }
443
444    async fn decode_bytes(
445        &self,
446        response: reqwest::Response,
447    ) -> Result<Vec<u8>, BoardServerClientError> {
448        if response.status().is_success() {
449            response
450                .bytes()
451                .await
452                .map(|bytes| bytes.to_vec())
453                .map_err(Self::request_error)
454        } else {
455            Err(Self::api_error(response).await)
456        }
457    }
458
459    async fn api_error(response: reqwest::Response) -> BoardServerClientError {
460        let status = response.status();
461        let body = response.text().await.unwrap_or_default();
462        parse_error_body(status, &body)
463    }
464
465    fn request_error(err: reqwest::Error) -> BoardServerClientError {
466        BoardServerClientError {
467            status: StatusCode::SERVICE_UNAVAILABLE,
468            code: Some("request_failed".to_string()),
469            message: err.to_string(),
470        }
471    }
472}
473
474fn build_base_url(scheme: &str, server: &str, port: u16) -> anyhow::Result<Url> {
475    let mut url = Url::parse(&format!("{scheme}://localhost"))
476        .with_context(|| format!("failed to create {scheme} URL"))?;
477    url.set_host(Some(server))
478        .map_err(|_| anyhow::anyhow!("invalid server host `{server}`"))?;
479    url.set_port(Some(port))
480        .map_err(|_| anyhow::anyhow!("invalid port `{port}`"))?;
481    Ok(url)
482}
483
484fn parse_error_body(status: StatusCode, body: &str) -> BoardServerClientError {
485    match serde_json::from_str::<ErrorResponse>(body) {
486        Ok(error) => BoardServerClientError {
487            status,
488            code: Some(error.code),
489            message: error.message,
490        },
491        Err(_) if !body.trim().is_empty() => BoardServerClientError {
492            status,
493            code: None,
494            message: body.trim().to_string(),
495        },
496        Err(_) => BoardServerClientError {
497            status,
498            code: None,
499            message: format!("request failed with status {status}"),
500        },
501    }
502}
503
504impl fmt::Display for BoardTypeSummary {
505    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
506        write!(f, "{} ({}/{})", self.board_type, self.available, self.total)
507    }
508}
509
510#[cfg(test)]
511mod tests {
512    use reqwest::StatusCode;
513
514    use super::{BoardServerClient, BootConfig, parse_error_body};
515
516    #[test]
517    fn resolve_relative_ws_url_uses_server_defaults() {
518        let client = BoardServerClient::new("127.0.0.1", 8080).unwrap();
519        let url = client
520            .resolve_ws_url("/api/v1/sessions/demo/serial/ws")
521            .unwrap();
522        assert_eq!(
523            url.as_str(),
524            "ws://127.0.0.1:8080/api/v1/sessions/demo/serial/ws"
525        );
526    }
527
528    #[test]
529    fn resolve_absolute_ws_url_keeps_original_value() {
530        let client = BoardServerClient::new("127.0.0.1", 8080).unwrap();
531        let url = client
532            .resolve_ws_url("ws://10.0.0.2:9000/api/v1/sessions/demo/serial/ws")
533            .unwrap();
534        assert_eq!(
535            url.as_str(),
536            "ws://10.0.0.2:9000/api/v1/sessions/demo/serial/ws"
537        );
538    }
539
540    #[test]
541    fn parse_error_body_prefers_structured_api_errors() {
542        let error = parse_error_body(
543            StatusCode::CONFLICT,
544            r#"{"code":"conflict","message":"no available board for type `rk3568`"}"#,
545        );
546        assert_eq!(error.status, StatusCode::CONFLICT);
547        assert_eq!(error.code.as_deref(), Some("conflict"));
548        assert_eq!(error.message, "no available board for type `rk3568`");
549    }
550
551    #[test]
552    fn not_found_error_is_classified_as_missing_board_type() {
553        let error = parse_error_body(
554            StatusCode::NOT_FOUND,
555            r#"{"code":"not_found","message":"board type `rk3568` not found"}"#,
556        );
557        assert!(error.is_board_type_not_found_for("rk3568"));
558        assert!(!error.is_no_available_board_for("rk3568"));
559    }
560
561    #[test]
562    fn parse_uboot_boot_profile() {
563        let response: super::BootProfileResponse = serde_json::from_str(
564            r#"{
565                "boot": {
566                    "kind": "uboot",
567                    "use_tftp": true,
568                    "kernel_load_addr": "0x80200000",
569                    "fit_load_addr": "0x82200000",
570                    "bootm_addr": "0x82200000"
571                },
572                "server_ip": "10.0.0.2",
573                "netmask": "255.255.255.0",
574                "interface": "eth0"
575            }"#,
576        )
577        .unwrap();
578
579        assert_eq!(response.server_ip.as_deref(), Some("10.0.0.2"));
580        match response.boot {
581            BootConfig::Uboot(profile) => {
582                assert!(profile.use_tftp);
583                assert_eq!(profile.kernel_load_addr.as_deref(), Some("0x80200000"));
584                assert_eq!(profile.fit_load_addr.as_deref(), Some("0x82200000"));
585                assert_eq!(profile.bootm_addr.as_deref(), Some("0x82200000"));
586            }
587            BootConfig::Pxe(_) | BootConfig::UefiHttp(_) => panic!("expected uboot profile"),
588        }
589    }
590
591    #[test]
592    fn parse_static_uboot_boot_profile() {
593        let response: super::BootProfileResponse = serde_json::from_str(
594            r#"{
595                "boot": {
596                    "kind": "uboot",
597                    "use_tftp": true,
598                    "network_mode": "static_ip",
599                    "board_ip": "192.168.10.20",
600                    "server_ip": "192.168.10.2",
601                    "netmask": "255.255.255.0",
602                    "gatewayip": "192.168.10.1"
603                },
604                "server_ip": "192.168.10.2",
605                "netmask": "255.255.255.0",
606                "interface": "eth0"
607            }"#,
608        )
609        .unwrap();
610
611        match response.boot {
612            BootConfig::Uboot(profile) => {
613                assert!(profile.use_tftp);
614                assert_eq!(profile.network_mode, super::UbootNetworkMode::StaticIp);
615                assert_eq!(profile.board_ip.as_deref(), Some("192.168.10.20"));
616                assert_eq!(profile.server_ip.as_deref(), Some("192.168.10.2"));
617                assert_eq!(profile.netmask.as_deref(), Some("255.255.255.0"));
618                assert_eq!(profile.gatewayip.as_deref(), Some("192.168.10.1"));
619            }
620            BootConfig::Pxe(_) => panic!("expected uboot profile"),
621            BootConfig::UefiHttp(_) => panic!("expected uboot profile"),
622        }
623    }
624
625    #[test]
626    fn parse_httpboot_boot_profile() {
627        let response: super::BootProfileResponse = serde_json::from_str(
628            r#"{
629                "boot": {
630                    "kind": "httpboot",
631                    "boot_arch": "x86_64",
632                    "mac": "1c:69:7a:dc:f3:47"
633                },
634                "server_ip": null,
635                "netmask": null,
636                "interface": null
637            }"#,
638        )
639        .unwrap();
640
641        match response.boot {
642            BootConfig::UefiHttp(profile) => {
643                assert_eq!(profile.boot_arch, Some(super::UefiBootArch::X86_64));
644                assert_eq!(profile.mac.as_deref(), Some("1c:69:7a:dc:f3:47"));
645            }
646            _ => panic!("expected httpboot profile"),
647        }
648    }
649
650    #[test]
651    fn parse_tftp_session_file_response() {
652        let response: super::TftpSessionResponse = serde_json::from_str(
653            r#"{
654                "available": true,
655                "provider": "builtin",
656                "server_ip": "10.0.0.2",
657                "netmask": "255.255.255.0",
658                "writable": true,
659                "files": [
660                    {
661                        "filename": "image.fit",
662                        "relative_path": "ostool/sessions/demo/boot/image.fit",
663                        "tftp_url": "tftp://10.0.0.2/ostool/sessions/demo/boot/image.fit",
664                        "size": 1234,
665                        "uploaded_at": "2026-04-01T00:00:00Z"
666                    }
667                ]
668            }"#,
669        )
670        .unwrap();
671
672        assert!(response.available);
673        assert_eq!(response.files.len(), 1);
674        assert_eq!(response.files[0].filename, "image.fit");
675    }
676
677    #[test]
678    fn parse_session_dtb_response() {
679        let response: super::SessionDtbResponse = serde_json::from_str(
680            r#"{
681                "dtb_name": "board.dtb",
682                "relative_path": "ostool/sessions/demo/boot/dtb/board.dtb",
683                "session_file_path": "boot/dtb/board.dtb",
684                "tftp_url": "tftp://10.0.0.2/ostool/sessions/demo/boot/dtb/board.dtb"
685            }"#,
686        )
687        .unwrap();
688
689        assert_eq!(response.dtb_name.as_deref(), Some("board.dtb"));
690        assert_eq!(
691            response.session_file_path.as_deref(),
692            Some("boot/dtb/board.dtb")
693        );
694    }
695}