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}