1use serde_json::Value;
11
12use crate::error::{Error, Result};
13
14fn get_str(v: &Value, key: &str) -> String {
17 v.get(key)
18 .and_then(Value::as_str)
19 .unwrap_or_default()
20 .to_string()
21}
22
23fn get_opt_str(v: &Value, key: &str) -> Option<String> {
24 v.get(key).and_then(Value::as_str).map(str::to_string)
25}
26
27fn get_bool(v: &Value, key: &str) -> bool {
28 v.get(key).and_then(Value::as_bool).unwrap_or(false)
29}
30
31fn get_num(v: &Value, key: &str) -> Option<i64> {
34 match v.get(key) {
35 Some(Value::Number(n)) => n.as_i64().or_else(|| n.as_f64().map(|f| f as i64)),
36 Some(Value::String(s)) => s.trim().parse().ok(),
37 _ => None,
38 }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct PortForward {
49 pub host: u16,
50 pub guest: u16,
51 pub bind: String,
52}
53
54impl PortForward {
55 pub fn from_row(row: &Value) -> PortForward {
56 PortForward {
57 host: get_num(row, "host").unwrap_or(0) as u16,
58 guest: get_num(row, "guest").unwrap_or(0) as u16,
59 bind: {
60 let bind = get_str(row, "bind");
61 if bind.is_empty() {
62 "127.0.0.1".to_string()
63 } else {
64 bind
65 }
66 },
67 }
68 }
69}
70
71#[derive(Debug, Clone, PartialEq)]
74pub struct SandboxInfo {
75 pub id: String,
76 pub name: Option<String>,
77 pub image: String,
78 pub kind: String,
79 pub command: String,
80 pub status: String,
81 pub running: bool,
82 pub exit_code: Option<i32>,
83 pub pid: Option<i64>,
84 pub detached: bool,
85 pub cpus: u32,
86 pub mem: u64,
87 pub volume: Option<String>,
88 pub state_dir: String,
89 pub network: Option<String>,
90 pub net_ip: Option<String>,
91 pub ports: Vec<PortForward>,
92 pub created_at: i64,
93 pub finished_at: Option<i64>,
94 pub origin: Option<String>,
96}
97
98#[derive(Debug, Clone, PartialEq)]
103pub struct AiAgent {
104 pub id: String,
106 pub label: String,
107 pub flavor: String,
109 pub description: String,
110 pub installed: bool,
113 pub running: i64,
114}
115
116impl AiAgent {
117 pub fn from_graphql(a: &Value) -> AiAgent {
118 AiAgent {
119 id: get_str(a, "id"),
120 label: get_str(a, "label"),
121 flavor: get_str(a, "flavor"),
122 description: get_str(a, "description"),
123 installed: get_bool(a, "installed"),
124 running: get_num(a, "running").unwrap_or(0),
125 }
126 }
127}
128
129#[derive(Debug, Clone, PartialEq)]
131pub struct AiSession {
132 pub id: String,
133 pub name: String,
134 pub agent: String,
135 pub running: bool,
136 pub workspace: Option<String>,
138 pub created_at: i64,
140}
141
142impl AiSession {
143 pub fn from_graphql(s: &Value) -> AiSession {
144 AiSession {
145 id: get_str(s, "id"),
146 name: get_str(s, "name"),
147 agent: get_str(s, "agent"),
148 running: get_bool(s, "running"),
149 workspace: get_opt_str(s, "workspace"),
150 created_at: get_num(s, "createdAt").unwrap_or(0),
151 }
152 }
153}
154
155#[derive(Debug, Clone, PartialEq)]
160pub struct DockerStatus {
161 pub running: bool,
162 pub machine_id: Option<String>,
163 pub machine_running: bool,
164 pub socket: String,
166 pub socket_ready: bool,
167 pub api_port: Option<u16>,
168 pub version: Option<String>,
169 pub containers: Option<i64>,
170 pub images: Option<i64>,
171 pub mounts: Vec<String>,
173 pub disk: Option<String>,
175 pub disk_size: Option<u64>,
177}
178
179impl DockerStatus {
180 pub fn from_graphql(s: &Value) -> DockerStatus {
181 DockerStatus {
182 running: get_bool(s, "running"),
183 machine_id: get_opt_str(s, "machineId"),
184 machine_running: get_bool(s, "machineRunning"),
185 socket: get_str(s, "socket"),
186 socket_ready: get_bool(s, "socketReady"),
187 api_port: get_num(s, "apiPort").map(|v| v as u16),
188 version: get_opt_str(s, "version"),
189 containers: get_num(s, "containers"),
190 images: get_num(s, "images"),
191 mounts: strings_from(s.get("mounts")),
192 disk: get_opt_str(s, "disk"),
193 disk_size: get_num(s, "diskSize").map(|v| v as u64),
194 }
195 }
196}
197
198#[derive(Debug, Clone, PartialEq)]
200pub struct DockerContainer {
201 pub id: String,
202 pub name: String,
203 pub image: String,
204 pub command: String,
205 pub state: String,
207 pub status: String,
209 pub ports: Vec<String>,
211 pub created: i64,
213}
214
215impl DockerContainer {
216 pub fn from_graphql(c: &Value) -> DockerContainer {
217 DockerContainer {
218 id: get_str(c, "id"),
219 name: get_str(c, "name"),
220 image: get_str(c, "image"),
221 command: get_str(c, "command"),
222 state: get_str(c, "state"),
223 status: get_str(c, "status"),
224 ports: strings_from(c.get("ports")),
225 created: get_num(c, "created").unwrap_or(0),
226 }
227 }
228
229 pub fn is_running(&self) -> bool {
231 self.state == "running"
232 }
233}
234
235fn strings_from(v: Option<&Value>) -> Vec<String> {
236 v.and_then(Value::as_array)
237 .map(|xs| {
238 xs.iter()
239 .filter_map(Value::as_str)
240 .map(str::to_string)
241 .collect()
242 })
243 .unwrap_or_default()
244}
245
246#[derive(Debug, Clone, PartialEq)]
252pub struct SnapshotInfo {
253 pub id: String,
254 pub name: String,
255 pub machine_id: String,
256 pub machine_name: String,
258 pub kind: String,
260 pub image: String,
261 pub path: String,
262 pub parent: Option<String>,
264 pub description: String,
265 pub cpus: u32,
266 pub mem: u64,
267 pub ports: Vec<PortForward>,
268 pub size: Option<String>,
270 pub created_at: i64,
271}
272
273impl SnapshotInfo {
274 pub fn from_graphql(s: &Value) -> SnapshotInfo {
276 SnapshotInfo {
277 id: get_str(s, "id"),
278 name: get_str(s, "name"),
279 machine_id: get_str(s, "machineId"),
280 machine_name: get_str(s, "machineName"),
281 kind: get_str(s, "kind"),
282 image: get_str(s, "image"),
283 path: get_str(s, "path"),
284 parent: get_opt_str(s, "parent"),
285 description: get_str(s, "description"),
286 cpus: get_num(s, "cpus").unwrap_or(0) as u32,
287 mem: get_num(s, "mem").unwrap_or(0) as u64,
288 ports: ports_from(s.get("ports")),
289 size: get_opt_str(s, "size"),
290 created_at: get_num(s, "createdAt").unwrap_or(0),
291 }
292 }
293
294 pub fn from_row(row: &Value) -> SnapshotInfo {
296 SnapshotInfo {
297 id: get_str(row, "id"),
298 name: get_str(row, "name"),
299 machine_id: get_str(row, "machine_id"),
300 machine_name: get_str(row, "machine_name"),
301 kind: get_str(row, "kind"),
302 image: get_str(row, "image"),
303 path: get_str(row, "path"),
304 parent: get_opt_str(row, "parent"),
305 description: get_str(row, "description"),
306 cpus: get_num(row, "cpus").unwrap_or(0) as u32,
307 mem: get_num(row, "mem").unwrap_or(0) as u64,
308 ports: ports_from(row.get("ports")),
309 size: get_opt_str(row, "size"),
310 created_at: get_num(row, "created_at").unwrap_or(0),
311 }
312 }
313}
314
315impl SandboxInfo {
316 pub fn from_row(row: &Value) -> SandboxInfo {
318 let running = get_bool(row, "running");
319 SandboxInfo {
320 id: get_str(row, "id"),
321 name: get_opt_str(row, "name"),
322 image: get_str(row, "image"),
323 kind: get_str(row, "kind"),
324 command: get_str(row, "command"),
325 status: if running { "running" } else { "exited" }.to_string(),
326 running,
327 exit_code: get_num(row, "exit_code").map(|v| v as i32),
328 pid: get_num(row, "pid"),
329 detached: get_bool(row, "detached"),
330 cpus: get_num(row, "cpus").unwrap_or(0) as u32,
331 mem: get_num(row, "mem").unwrap_or(0) as u64,
332 volume: get_opt_str(row, "volume"),
333 state_dir: get_str(row, "state_dir"),
334 network: get_opt_str(row, "network"),
335 net_ip: get_opt_str(row, "net_ip"),
336 ports: ports_from(row.get("ports")),
337 created_at: get_num(row, "created_at").unwrap_or(0),
338 finished_at: get_num(row, "finished_at"),
339 origin: get_opt_str(row, "origin"),
340 }
341 }
342
343 pub fn from_graphql(m: &Value) -> SandboxInfo {
345 let running = get_bool(m, "running");
346 let status = {
347 let s = get_str(m, "status");
348 if s.is_empty() {
349 if running { "running" } else { "exited" }.to_string()
350 } else {
351 s
352 }
353 };
354 SandboxInfo {
355 id: get_str(m, "id"),
356 name: get_opt_str(m, "name"),
357 image: get_str(m, "image"),
358 kind: get_str(m, "kind"),
359 command: get_str(m, "command"),
360 status,
361 running,
362 exit_code: get_num(m, "exitCode").map(|v| v as i32),
363 pid: get_num(m, "pid"),
364 detached: get_bool(m, "detached"),
365 cpus: get_num(m, "cpus").unwrap_or(0) as u32,
366 mem: get_num(m, "mem").unwrap_or(0) as u64,
367 volume: get_opt_str(m, "volume"),
368 state_dir: get_str(m, "stateDir"),
369 network: get_opt_str(m, "network"),
370 net_ip: get_opt_str(m, "netIp"),
371 ports: ports_from(m.get("ports")),
372 created_at: get_num(m, "createdAt").unwrap_or(0),
373 finished_at: get_num(m, "finishedAt"),
374 origin: get_opt_str(m, "origin"),
375 }
376 }
377}
378
379fn ports_from(v: Option<&Value>) -> Vec<PortForward> {
380 v.and_then(Value::as_array)
381 .map(|rows| rows.iter().map(PortForward::from_row).collect())
382 .unwrap_or_default()
383}
384
385#[derive(Debug, Clone, PartialEq)]
387pub struct ImageInfo {
388 pub id: String,
389 pub reference: String,
390 pub digest: String,
391 pub size: u64,
392 pub rootfs: String,
393 pub created_at: i64,
394}
395
396impl ImageInfo {
397 pub fn from_row(row: &Value) -> ImageInfo {
398 ImageInfo {
399 id: get_str(row, "id"),
400 reference: get_str(row, "reference"),
401 digest: get_str(row, "digest"),
402 size: get_num(row, "size").unwrap_or(0) as u64,
403 rootfs: get_str(row, "rootfs"),
404 created_at: get_num(row, "created_at").unwrap_or(0),
405 }
406 }
407}
408
409#[derive(Debug, Clone, PartialEq)]
411pub struct VolumeInfo {
412 pub name: String,
413 pub guest: Option<String>,
414 pub base: Option<String>,
415 pub path: String,
416 pub size: String,
417 pub created_at: Option<i64>,
418 pub tracked: bool,
419}
420
421impl VolumeInfo {
422 pub fn from_row(row: &Value) -> VolumeInfo {
423 VolumeInfo {
424 name: get_str(row, "name"),
425 guest: get_opt_str(row, "guest"),
426 base: get_opt_str(row, "base"),
427 path: get_str(row, "path"),
428 size: get_str(row, "size"),
429 created_at: get_num(row, "created_at"),
430 tracked: get_bool(row, "tracked"),
431 }
432 }
433}
434
435#[derive(Debug, Clone, PartialEq)]
437pub struct NetworkInfo {
438 pub name: String,
439 pub subnet: String,
440 pub gateway: String,
441 pub members: u32,
442 pub running: u32,
443 pub up: bool,
444 pub created_at: Option<i64>,
445}
446
447impl NetworkInfo {
448 pub fn from_row(row: &Value) -> NetworkInfo {
449 NetworkInfo {
450 name: get_str(row, "name"),
451 subnet: get_str(row, "subnet"),
452 gateway: get_str(row, "gateway"),
453 members: get_num(row, "members").unwrap_or(0) as u32,
454 running: get_num(row, "running").unwrap_or(0) as u32,
455 up: get_bool(row, "up"),
456 created_at: get_num(row, "created_at"),
457 }
458 }
459}
460
461#[derive(Debug, Clone, PartialEq, Eq)]
468pub struct CommandResult {
469 pub exit_code: i32,
470 pub stdout: String,
471 pub stderr: String,
472}
473
474impl CommandResult {
475 pub fn from_graphql(r: &Value) -> CommandResult {
476 CommandResult {
477 exit_code: get_num(r, "exitCode").unwrap_or(0) as i32,
478 stdout: get_str(r, "stdout"),
479 stderr: get_str(r, "stderr"),
480 }
481 }
482}
483
484#[derive(Debug, Clone, PartialEq, Eq)]
486pub struct ShellSessionInfo {
487 pub id: String,
488 pub machine_id: String,
489 pub finished: bool,
490 pub truncated: bool,
491}
492
493impl ShellSessionInfo {
494 pub fn from_graphql(s: &Value) -> ShellSessionInfo {
495 ShellSessionInfo {
496 id: get_str(s, "id"),
497 machine_id: get_str(s, "machineId"),
498 finished: get_bool(s, "finished"),
499 truncated: get_bool(s, "truncated"),
500 }
501 }
502}
503
504#[derive(Debug, Clone, PartialEq, Eq)]
508pub struct ExecResult {
509 pub stdout: String,
510 pub stderr: String,
511 pub exit_code: i32,
512 pub command: String,
514}
515
516impl ExecResult {
517 pub fn ok(&self) -> bool {
519 self.exit_code == 0
520 }
521
522 pub fn text(&self) -> &str {
524 self.stdout.trim_end_matches('\n')
525 }
526
527 pub fn json<T: serde::de::DeserializeOwned>(&self) -> Result<T> {
529 Ok(serde_json::from_str(&self.stdout)?)
530 }
531
532 pub fn lines(&self) -> Vec<String> {
534 self.stdout
535 .split('\n')
536 .filter(|line| !line.is_empty())
537 .map(str::to_string)
538 .collect()
539 }
540
541 pub fn ok_or_err(self) -> Result<ExecResult> {
544 if self.exit_code != 0 {
545 return Err(Error::CommandFailed {
546 exit_code: self.exit_code,
547 stdout: self.stdout,
548 stderr: self.stderr,
549 command: self.command,
550 });
551 }
552 Ok(self)
553 }
554}
555
556#[derive(Debug, Clone, PartialEq, Eq)]
563pub struct RemoteExecResult {
564 pub exit_code: i32,
565 pub output: Vec<u8>,
566}
567
568impl RemoteExecResult {
569 pub fn ok(&self) -> bool {
571 self.exit_code == 0
572 }
573
574 pub fn text(&self) -> String {
576 String::from_utf8_lossy(&self.output).into_owned()
577 }
578}
579
580#[cfg(test)]
581mod tests {
582 use super::*;
583 use serde_json::json;
584
585 #[test]
586 fn sandbox_info_from_row_running() {
587 let info = SandboxInfo::from_row(&json!({
588 "id": "abc123def456",
589 "name": null,
590 "image": "alpine",
591 "kind": "linux",
592 "command": "sleep 300",
593 "running": true,
594 "exit_code": null,
595 "pid": 4242,
596 "detached": true,
597 "cpus": 2,
598 "mem": 1024,
599 "volume": null,
600 "state_dir": "/var/lib/bsdkrun/abc123",
601 "network": "devnet",
602 "net_ip": "192.168.127.3",
603 "created_at": 1700000000i64,
604 "finished_at": null,
605 }));
606 assert_eq!(info.status, "running");
607 assert!(info.running);
608 assert_eq!(info.exit_code, None);
609 assert_eq!(info.pid, Some(4242));
610 assert_eq!(info.network.as_deref(), Some("devnet"));
611 }
612
613 #[test]
614 fn sandbox_info_from_row_exited_defaults() {
615 let info = SandboxInfo::from_row(&json!({
616 "id": "abc",
617 "image": "alpine",
618 "kind": "linux",
619 "running": false,
620 "exit_code": 0,
621 "detached": true,
622 "cpus": 1,
623 "mem": 512,
624 "state_dir": "/s",
625 "created_at": 1,
626 "finished_at": 2,
627 }));
628 assert_eq!(info.status, "exited");
629 assert_eq!(info.command, "");
630 assert_eq!(info.finished_at, Some(2));
631 }
632
633 #[test]
634 fn sandbox_info_from_graphql_maps_camel_case_and_string_timestamps() {
635 let info = SandboxInfo::from_graphql(&json!({
636 "id": "abc123def456",
637 "name": null,
638 "image": "alpine",
639 "kind": "linux",
640 "command": "sleep 300",
641 "status": "running",
642 "running": true,
643 "exitCode": null,
644 "pid": 4242,
645 "detached": true,
646 "cpus": 2,
647 "mem": 1024,
648 "volume": null,
649 "stateDir": "/var/lib/bsdkrun/abc123",
650 "network": "devnet",
651 "netIp": "192.168.127.3",
652 "ports": [{"bind": "0.0.0.0", "host": 2222, "guest": 22}],
653 "createdAt": "1700000000",
654 "finishedAt": null,
655 }));
656 assert_eq!(info.state_dir, "/var/lib/bsdkrun/abc123");
657 assert_eq!(info.net_ip.as_deref(), Some("192.168.127.3"));
658 assert_eq!(info.created_at, 1700000000);
659 assert_eq!(info.finished_at, None);
660 assert_eq!(
661 info.ports,
662 vec![PortForward {
663 host: 2222,
664 guest: 22,
665 bind: "0.0.0.0".into()
666 }]
667 );
668 }
669
670 #[test]
671 fn graphql_floats_coerce_back_to_ints() {
672 let info = SandboxInfo::from_graphql(&json!({
675 "id": "abc",
676 "image": "alpine",
677 "kind": "linux",
678 "command": "",
679 "status": "exited",
680 "running": false,
681 "exitCode": 0,
682 "pid": 4242.0,
683 "detached": true,
684 "cpus": 1,
685 "mem": 512.0,
686 "stateDir": "/s",
687 "createdAt": "1",
688 "finishedAt": "2",
689 }));
690 assert_eq!(info.pid, Some(4242));
691 assert_eq!(info.mem, 512);
692 assert_eq!(info.finished_at, Some(2));
693 }
694
695 #[test]
696 fn volume_and_network_rows() {
697 let vol = VolumeInfo::from_row(&json!({
698 "name": "web", "path": "/p", "size": "1G", "tracked": true
699 }));
700 assert_eq!(vol.name, "web");
701 assert_eq!(vol.guest, None);
702 assert_eq!(vol.created_at, None);
703
704 let net = NetworkInfo::from_row(&json!({
705 "name": "devnet",
706 "subnet": "192.168.127.0/24",
707 "gateway": "192.168.127.1",
708 "members": 2,
709 "running": 1,
710 "up": true,
711 }));
712 assert_eq!(net.members, 2);
713 assert!(net.up);
714 }
715
716 #[test]
717 fn command_result_defaults_sanely() {
718 let full = CommandResult::from_graphql(&json!({
719 "exitCode": 1, "stdout": "out", "stderr": "err"
720 }));
721 assert_eq!((full.exit_code, full.stdout.as_str()), (1, "out"));
722
723 let empty = CommandResult::from_graphql(&json!({}));
724 assert_eq!(empty.exit_code, 0);
725 assert_eq!(empty.stdout, "");
726 }
727
728 #[test]
729 fn shell_session_info_from_graphql() {
730 let s = ShellSessionInfo::from_graphql(&json!({
731 "id": "sess-1", "machineId": "abc123", "finished": false, "truncated": true
732 }));
733 assert_eq!(s.id, "sess-1");
734 assert_eq!(s.machine_id, "abc123");
735 assert!(!s.finished);
736 assert!(s.truncated);
737 }
738
739 #[test]
740 fn exec_result_helpers() {
741 let ok = ExecResult {
742 stdout: "hello\n\n".into(),
743 stderr: String::new(),
744 exit_code: 0,
745 command: "echo".into(),
746 };
747 assert!(ok.ok());
748 assert_eq!(ok.text(), "hello");
749 assert_eq!(ok.lines(), vec!["hello".to_string()]);
750 assert!(ok.ok_or_err().is_ok());
751
752 let failed = ExecResult {
753 stdout: String::new(),
754 stderr: "boom".into(),
755 exit_code: 1,
756 command: "false".into(),
757 };
758 assert!(!failed.ok());
759 assert!(matches!(
760 failed.ok_or_err(),
761 Err(Error::CommandFailed { exit_code: 1, .. })
762 ));
763 }
764
765 #[test]
766 fn exec_result_json_parses_stdout() {
767 let r = ExecResult {
768 stdout: "{\"a\": 1}".into(),
769 stderr: String::new(),
770 exit_code: 0,
771 command: "cat".into(),
772 };
773 let v: serde_json::Value = r.json().unwrap();
774 assert_eq!(v["a"], 1);
775 }
776}