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}
95
96impl SandboxInfo {
97 pub fn from_row(row: &Value) -> SandboxInfo {
99 let running = get_bool(row, "running");
100 SandboxInfo {
101 id: get_str(row, "id"),
102 name: get_opt_str(row, "name"),
103 image: get_str(row, "image"),
104 kind: get_str(row, "kind"),
105 command: get_str(row, "command"),
106 status: if running { "running" } else { "exited" }.to_string(),
107 running,
108 exit_code: get_num(row, "exit_code").map(|v| v as i32),
109 pid: get_num(row, "pid"),
110 detached: get_bool(row, "detached"),
111 cpus: get_num(row, "cpus").unwrap_or(0) as u32,
112 mem: get_num(row, "mem").unwrap_or(0) as u64,
113 volume: get_opt_str(row, "volume"),
114 state_dir: get_str(row, "state_dir"),
115 network: get_opt_str(row, "network"),
116 net_ip: get_opt_str(row, "net_ip"),
117 ports: ports_from(row.get("ports")),
118 created_at: get_num(row, "created_at").unwrap_or(0),
119 finished_at: get_num(row, "finished_at"),
120 }
121 }
122
123 pub fn from_graphql(m: &Value) -> SandboxInfo {
125 let running = get_bool(m, "running");
126 let status = {
127 let s = get_str(m, "status");
128 if s.is_empty() {
129 if running { "running" } else { "exited" }.to_string()
130 } else {
131 s
132 }
133 };
134 SandboxInfo {
135 id: get_str(m, "id"),
136 name: get_opt_str(m, "name"),
137 image: get_str(m, "image"),
138 kind: get_str(m, "kind"),
139 command: get_str(m, "command"),
140 status,
141 running,
142 exit_code: get_num(m, "exitCode").map(|v| v as i32),
143 pid: get_num(m, "pid"),
144 detached: get_bool(m, "detached"),
145 cpus: get_num(m, "cpus").unwrap_or(0) as u32,
146 mem: get_num(m, "mem").unwrap_or(0) as u64,
147 volume: get_opt_str(m, "volume"),
148 state_dir: get_str(m, "stateDir"),
149 network: get_opt_str(m, "network"),
150 net_ip: get_opt_str(m, "netIp"),
151 ports: ports_from(m.get("ports")),
152 created_at: get_num(m, "createdAt").unwrap_or(0),
153 finished_at: get_num(m, "finishedAt"),
154 }
155 }
156}
157
158fn ports_from(v: Option<&Value>) -> Vec<PortForward> {
159 v.and_then(Value::as_array)
160 .map(|rows| rows.iter().map(PortForward::from_row).collect())
161 .unwrap_or_default()
162}
163
164#[derive(Debug, Clone, PartialEq)]
166pub struct ImageInfo {
167 pub id: String,
168 pub reference: String,
169 pub digest: String,
170 pub size: u64,
171 pub rootfs: String,
172 pub created_at: i64,
173}
174
175impl ImageInfo {
176 pub fn from_row(row: &Value) -> ImageInfo {
177 ImageInfo {
178 id: get_str(row, "id"),
179 reference: get_str(row, "reference"),
180 digest: get_str(row, "digest"),
181 size: get_num(row, "size").unwrap_or(0) as u64,
182 rootfs: get_str(row, "rootfs"),
183 created_at: get_num(row, "created_at").unwrap_or(0),
184 }
185 }
186}
187
188#[derive(Debug, Clone, PartialEq)]
190pub struct VolumeInfo {
191 pub name: String,
192 pub guest: Option<String>,
193 pub base: Option<String>,
194 pub path: String,
195 pub size: String,
196 pub created_at: Option<i64>,
197 pub tracked: bool,
198}
199
200impl VolumeInfo {
201 pub fn from_row(row: &Value) -> VolumeInfo {
202 VolumeInfo {
203 name: get_str(row, "name"),
204 guest: get_opt_str(row, "guest"),
205 base: get_opt_str(row, "base"),
206 path: get_str(row, "path"),
207 size: get_str(row, "size"),
208 created_at: get_num(row, "created_at"),
209 tracked: get_bool(row, "tracked"),
210 }
211 }
212}
213
214#[derive(Debug, Clone, PartialEq)]
216pub struct NetworkInfo {
217 pub name: String,
218 pub subnet: String,
219 pub gateway: String,
220 pub members: u32,
221 pub running: u32,
222 pub up: bool,
223 pub created_at: Option<i64>,
224}
225
226impl NetworkInfo {
227 pub fn from_row(row: &Value) -> NetworkInfo {
228 NetworkInfo {
229 name: get_str(row, "name"),
230 subnet: get_str(row, "subnet"),
231 gateway: get_str(row, "gateway"),
232 members: get_num(row, "members").unwrap_or(0) as u32,
233 running: get_num(row, "running").unwrap_or(0) as u32,
234 up: get_bool(row, "up"),
235 created_at: get_num(row, "created_at"),
236 }
237 }
238}
239
240#[derive(Debug, Clone, PartialEq, Eq)]
247pub struct CommandResult {
248 pub exit_code: i32,
249 pub stdout: String,
250 pub stderr: String,
251}
252
253impl CommandResult {
254 pub fn from_graphql(r: &Value) -> CommandResult {
255 CommandResult {
256 exit_code: get_num(r, "exitCode").unwrap_or(0) as i32,
257 stdout: get_str(r, "stdout"),
258 stderr: get_str(r, "stderr"),
259 }
260 }
261}
262
263#[derive(Debug, Clone, PartialEq, Eq)]
265pub struct ShellSessionInfo {
266 pub id: String,
267 pub machine_id: String,
268 pub finished: bool,
269 pub truncated: bool,
270}
271
272impl ShellSessionInfo {
273 pub fn from_graphql(s: &Value) -> ShellSessionInfo {
274 ShellSessionInfo {
275 id: get_str(s, "id"),
276 machine_id: get_str(s, "machineId"),
277 finished: get_bool(s, "finished"),
278 truncated: get_bool(s, "truncated"),
279 }
280 }
281}
282
283#[derive(Debug, Clone, PartialEq, Eq)]
287pub struct ExecResult {
288 pub stdout: String,
289 pub stderr: String,
290 pub exit_code: i32,
291 pub command: String,
293}
294
295impl ExecResult {
296 pub fn ok(&self) -> bool {
298 self.exit_code == 0
299 }
300
301 pub fn text(&self) -> &str {
303 self.stdout.trim_end_matches('\n')
304 }
305
306 pub fn json<T: serde::de::DeserializeOwned>(&self) -> Result<T> {
308 Ok(serde_json::from_str(&self.stdout)?)
309 }
310
311 pub fn lines(&self) -> Vec<String> {
313 self.stdout
314 .split('\n')
315 .filter(|line| !line.is_empty())
316 .map(str::to_string)
317 .collect()
318 }
319
320 pub fn ok_or_err(self) -> Result<ExecResult> {
323 if self.exit_code != 0 {
324 return Err(Error::CommandFailed {
325 exit_code: self.exit_code,
326 stdout: self.stdout,
327 stderr: self.stderr,
328 command: self.command,
329 });
330 }
331 Ok(self)
332 }
333}
334
335#[derive(Debug, Clone, PartialEq, Eq)]
342pub struct RemoteExecResult {
343 pub exit_code: i32,
344 pub output: Vec<u8>,
345}
346
347impl RemoteExecResult {
348 pub fn ok(&self) -> bool {
350 self.exit_code == 0
351 }
352
353 pub fn text(&self) -> String {
355 String::from_utf8_lossy(&self.output).into_owned()
356 }
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362 use serde_json::json;
363
364 #[test]
365 fn sandbox_info_from_row_running() {
366 let info = SandboxInfo::from_row(&json!({
367 "id": "abc123def456",
368 "name": null,
369 "image": "alpine",
370 "kind": "linux",
371 "command": "sleep 300",
372 "running": true,
373 "exit_code": null,
374 "pid": 4242,
375 "detached": true,
376 "cpus": 2,
377 "mem": 1024,
378 "volume": null,
379 "state_dir": "/var/lib/bsdkrun/abc123",
380 "network": "devnet",
381 "net_ip": "192.168.127.3",
382 "created_at": 1700000000i64,
383 "finished_at": null,
384 }));
385 assert_eq!(info.status, "running");
386 assert!(info.running);
387 assert_eq!(info.exit_code, None);
388 assert_eq!(info.pid, Some(4242));
389 assert_eq!(info.network.as_deref(), Some("devnet"));
390 }
391
392 #[test]
393 fn sandbox_info_from_row_exited_defaults() {
394 let info = SandboxInfo::from_row(&json!({
395 "id": "abc",
396 "image": "alpine",
397 "kind": "linux",
398 "running": false,
399 "exit_code": 0,
400 "detached": true,
401 "cpus": 1,
402 "mem": 512,
403 "state_dir": "/s",
404 "created_at": 1,
405 "finished_at": 2,
406 }));
407 assert_eq!(info.status, "exited");
408 assert_eq!(info.command, "");
409 assert_eq!(info.finished_at, Some(2));
410 }
411
412 #[test]
413 fn sandbox_info_from_graphql_maps_camel_case_and_string_timestamps() {
414 let info = SandboxInfo::from_graphql(&json!({
415 "id": "abc123def456",
416 "name": null,
417 "image": "alpine",
418 "kind": "linux",
419 "command": "sleep 300",
420 "status": "running",
421 "running": true,
422 "exitCode": null,
423 "pid": 4242,
424 "detached": true,
425 "cpus": 2,
426 "mem": 1024,
427 "volume": null,
428 "stateDir": "/var/lib/bsdkrun/abc123",
429 "network": "devnet",
430 "netIp": "192.168.127.3",
431 "ports": [{"bind": "0.0.0.0", "host": 2222, "guest": 22}],
432 "createdAt": "1700000000",
433 "finishedAt": null,
434 }));
435 assert_eq!(info.state_dir, "/var/lib/bsdkrun/abc123");
436 assert_eq!(info.net_ip.as_deref(), Some("192.168.127.3"));
437 assert_eq!(info.created_at, 1700000000);
438 assert_eq!(info.finished_at, None);
439 assert_eq!(
440 info.ports,
441 vec![PortForward {
442 host: 2222,
443 guest: 22,
444 bind: "0.0.0.0".into()
445 }]
446 );
447 }
448
449 #[test]
450 fn graphql_floats_coerce_back_to_ints() {
451 let info = SandboxInfo::from_graphql(&json!({
454 "id": "abc",
455 "image": "alpine",
456 "kind": "linux",
457 "command": "",
458 "status": "exited",
459 "running": false,
460 "exitCode": 0,
461 "pid": 4242.0,
462 "detached": true,
463 "cpus": 1,
464 "mem": 512.0,
465 "stateDir": "/s",
466 "createdAt": "1",
467 "finishedAt": "2",
468 }));
469 assert_eq!(info.pid, Some(4242));
470 assert_eq!(info.mem, 512);
471 assert_eq!(info.finished_at, Some(2));
472 }
473
474 #[test]
475 fn volume_and_network_rows() {
476 let vol = VolumeInfo::from_row(&json!({
477 "name": "web", "path": "/p", "size": "1G", "tracked": true
478 }));
479 assert_eq!(vol.name, "web");
480 assert_eq!(vol.guest, None);
481 assert_eq!(vol.created_at, None);
482
483 let net = NetworkInfo::from_row(&json!({
484 "name": "devnet",
485 "subnet": "192.168.127.0/24",
486 "gateway": "192.168.127.1",
487 "members": 2,
488 "running": 1,
489 "up": true,
490 }));
491 assert_eq!(net.members, 2);
492 assert!(net.up);
493 }
494
495 #[test]
496 fn command_result_defaults_sanely() {
497 let full = CommandResult::from_graphql(&json!({
498 "exitCode": 1, "stdout": "out", "stderr": "err"
499 }));
500 assert_eq!((full.exit_code, full.stdout.as_str()), (1, "out"));
501
502 let empty = CommandResult::from_graphql(&json!({}));
503 assert_eq!(empty.exit_code, 0);
504 assert_eq!(empty.stdout, "");
505 }
506
507 #[test]
508 fn shell_session_info_from_graphql() {
509 let s = ShellSessionInfo::from_graphql(&json!({
510 "id": "sess-1", "machineId": "abc123", "finished": false, "truncated": true
511 }));
512 assert_eq!(s.id, "sess-1");
513 assert_eq!(s.machine_id, "abc123");
514 assert!(!s.finished);
515 assert!(s.truncated);
516 }
517
518 #[test]
519 fn exec_result_helpers() {
520 let ok = ExecResult {
521 stdout: "hello\n\n".into(),
522 stderr: String::new(),
523 exit_code: 0,
524 command: "echo".into(),
525 };
526 assert!(ok.ok());
527 assert_eq!(ok.text(), "hello");
528 assert_eq!(ok.lines(), vec!["hello".to_string()]);
529 assert!(ok.ok_or_err().is_ok());
530
531 let failed = ExecResult {
532 stdout: String::new(),
533 stderr: "boom".into(),
534 exit_code: 1,
535 command: "false".into(),
536 };
537 assert!(!failed.ok());
538 assert!(matches!(
539 failed.ok_or_err(),
540 Err(Error::CommandFailed { exit_code: 1, .. })
541 ));
542 }
543
544 #[test]
545 fn exec_result_json_parses_stdout() {
546 let r = ExecResult {
547 stdout: "{\"a\": 1}".into(),
548 stderr: String::new(),
549 exit_code: 0,
550 command: "cat".into(),
551 };
552 let v: serde_json::Value = r.json().unwrap();
553 assert_eq!(v["a"], 1);
554 }
555}