Skip to main content

bsdkrun_sdk/
sandbox.rs

1//! The [`Sandbox`] handle — a running (or stopped) bsdkrun microVM — and the
2//! fluent builders that boot one.
3//!
4//! ```no_run
5//! use bsdkrun_sdk::Sandbox;
6//!
7//! let boxx = Sandbox::linux("alpine")
8//!     .cpus(2)
9//!     .mem(1024)
10//!     .port("8080:80")
11//!     .command(["sleep", "300"])
12//!     .create()?;
13//! println!("{}", boxx.exec(["uname", "-a"])?.text());
14//! boxx.stop()?;
15//! # Ok::<(), bsdkrun_sdk::Error>(())
16//! ```
17
18use serde_json::Value;
19
20use crate::args::{build_create_args, strvec, CreateOpts, Kind};
21use crate::error::{Error, Result};
22use crate::process::{checked, run_full, run_full_stream, spawn};
23use crate::types::{ExecResult, SandboxInfo};
24
25/// A machine id as the CLI prints it: lowercase hex, at least six digits, on a
26/// line of its own.
27fn looks_like_machine_id(line: &str) -> bool {
28    line.len() >= 6
29        && line
30            .chars()
31            .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
32}
33
34/// The host SSH port from the boot banner's `ssh -p <port>` hint, if any.
35fn parse_ssh_port(stderr: &str) -> Option<u16> {
36    let idx = stderr.find("ssh -p ")?;
37    let digits: String = stderr[idx + "ssh -p ".len()..]
38        .chars()
39        .take_while(|c| c.is_ascii_digit())
40        .collect();
41    digits.parse().ok()
42}
43
44/// A handle to a running (or stopped) bsdkrun microVM.
45///
46/// Create one with the per-kind builders ([`Sandbox::linux`],
47/// [`Sandbox::freebsd`], ...), reconnect with [`Sandbox::get`], or enumerate
48/// with [`Sandbox::list`].
49#[derive(Debug, Clone)]
50pub struct Sandbox {
51    id: String,
52    ssh_port: Option<u16>,
53}
54
55// -- shared setter macros -----------------------------------------------------
56//
57// Each create builder wraps the same `CreateOpts`; these macros stamp the
58// option groups (net/name/vm, disks) into each builder's impl so the groups
59// cannot drift between kinds — the same reason args.py factors them into
60// _net_args/_vm_args/_disk_args.
61
62macro_rules! common_setters {
63    () => {
64        /// Name the machine (`--name`).
65        pub fn name(mut self, name: impl Into<String>) -> Self {
66            self.opts.name = Some(name.into());
67            self
68        }
69
70        /// vCPU count (`--cpus`).
71        pub fn cpus(mut self, cpus: u32) -> Self {
72            self.opts.cpus = Some(cpus);
73            self
74        }
75
76        /// Guest RAM in MiB (`--mem`).
77        pub fn mem(mut self, mib: u32) -> Self {
78            self.opts.mem = Some(mib);
79            self
80        }
81
82        /// Add a host->guest TCP port forward, `"HOST:GUEST"`.
83        pub fn port(mut self, forward: impl Into<String>) -> Self {
84            self.opts.net.touched = true;
85            self.opts.net.ports.push(forward.into());
86            self
87        }
88
89        /// Add a port forward from numbers instead of a string.
90        pub fn forward(self, host: u16, guest: u16) -> Self {
91            self.port(format!("{host}:{guest}"))
92        }
93
94        /// Pin the guest MAC address (`--mac`).
95        pub fn mac(mut self, mac: impl Into<String>) -> Self {
96            self.opts.net.touched = true;
97            self.opts.net.mac = Some(mac.into());
98            self
99        }
100
101        /// Join a global network (`--network`).
102        pub fn network(mut self, network: impl Into<String>) -> Self {
103            self.opts.net.touched = true;
104            self.opts.net.network = Some(network.into());
105            self
106        }
107
108        /// Disable guest networking entirely (`--no-net`).
109        pub fn no_net(mut self) -> Self {
110            self.opts.net.touched = true;
111            self.opts.net.no_net = true;
112            self
113        }
114
115        /// bsdkrun's global `--log-level` for the create call (default 1, so
116        /// boot diagnostics land in the error when a boot fails).
117        pub fn log_level(mut self, level: u32) -> Self {
118            self.opts.log_level = Some(level);
119            self
120        }
121
122        /// The exact argv `create()` will run (minus the binary and global
123        /// flags) — for inspection and tests.
124        pub fn to_args(&self) -> Vec<String> {
125            build_create_args(self.kind, &self.opts)
126        }
127
128        /// Boot the machine (detached) and return a handle to it.
129        pub fn create(self) -> Result<Sandbox> {
130            Sandbox::create_with(self.kind, self.opts)
131        }
132    };
133}
134
135macro_rules! disk_setters {
136    () => {
137        /// Keep the root disk across `rm` (`--persist`).
138        pub fn persist(mut self) -> Self {
139            self.opts.persist = true;
140            self
141        }
142
143        /// Use a persistent CoW volume as the root disk (`-v`).
144        pub fn volume(mut self, name: impl Into<String>) -> Self {
145            self.opts.volume = Some(name.into());
146            self
147        }
148
149        /// Attach an extra raw disk, `"PATH"` or `"PATH:ro"` (`--attach-disk`).
150        pub fn attach_disk(mut self, disk: impl Into<String>) -> Self {
151            self.opts.attach_disk.push(disk.into());
152            self
153        }
154    };
155}
156
157macro_rules! define_builder {
158    ($(#[$doc:meta])* $name:ident) => {
159        $(#[$doc])*
160        #[derive(Debug, Clone)]
161        pub struct $name {
162            kind: Kind,
163            opts: CreateOpts,
164        }
165    };
166}
167
168define_builder!(
169    /// Boots an OCI image as a Linux microVM (`bsdkrun linux`).
170    LinuxBuilder
171);
172define_builder!(
173    /// Boots FreeBSD (`bsdkrun freebsd`).
174    FreebsdBuilder
175);
176define_builder!(
177    /// Boots NetBSD (`bsdkrun netbsd`).
178    NetbsdBuilder
179);
180define_builder!(
181    /// Boots a raw disk through its UEFI loader (`bsdkrun firmware`).
182    FirmwareBuilder
183);
184define_builder!(
185    /// Boots a kernel directly, no bootloader (`bsdkrun kernel`).
186    KernelBuilder
187);
188define_builder!(
189    /// Boots a Nanos unikernel image (`bsdkrun nanos`).
190    NanosBuilder
191);
192define_builder!(
193    /// Boots an OSv unikernel image (`bsdkrun osv`).
194    OsvBuilder
195);
196define_builder!(
197    /// Boots a Unikraft unikernel (`bsdkrun unikraft`).
198    UnikraftBuilder
199);
200define_builder!(
201    /// Boots a Solo5 (MirageOS) unikernel under the `solo5-hvt` tender
202    /// (`bsdkrun solo5`).
203    Solo5Builder
204);
205
206impl LinuxBuilder {
207    common_setters!();
208
209    /// Custom kernel image (`--kernel`).
210    pub fn kernel(mut self, kernel: impl Into<String>) -> Self {
211        self.opts.kernel = Some(kernel.into());
212        self
213    }
214
215    /// Kernel version to fetch (`--kernel-version`).
216    pub fn kernel_version(mut self, version: impl Into<String>) -> Self {
217        self.opts.kernel_version = Some(version.into());
218        self
219    }
220
221    /// Boot through an initramfs (`--initramfs`, a bare flag for Linux).
222    pub fn initramfs(mut self) -> Self {
223        self.opts.initramfs_flag = true;
224        self
225    }
226
227    /// Use a persistent CoW volume as the rootfs (`-v`).
228    pub fn volume(mut self, name: impl Into<String>) -> Self {
229        self.opts.volume = Some(name.into());
230        self
231    }
232
233    /// Share a host directory into the guest, `"HOST:GUEST"` or
234    /// `"HOST:GUEST:ro"` (`--mount`, repeatable).
235    pub fn mount(mut self, mount: impl Into<String>) -> Self {
236        self.opts.mounts.push(mount.into());
237        self
238    }
239
240    /// Override the image entrypoint (`--entrypoint`).
241    pub fn entrypoint(mut self, entrypoint: impl Into<String>) -> Self {
242        self.opts.entrypoint = Some(entrypoint.into());
243        self
244    }
245
246    /// Console device (`--console`).
247    pub fn console(mut self, console: impl Into<String>) -> Self {
248        self.opts.console = Some(console.into());
249        self
250    }
251
252    /// The command to run in the guest (argv after `--`).
253    pub fn command<I, S>(mut self, command: I) -> Self
254    where
255        I: IntoIterator<Item = S>,
256        S: Into<String>,
257    {
258        self.opts.command = strvec(command);
259        self
260    }
261}
262
263impl FreebsdBuilder {
264    common_setters!();
265    disk_setters!();
266
267    /// FreeBSD release to boot (`--version`).
268    pub fn version(mut self, version: impl Into<String>) -> Self {
269        self.opts.version = Some(version.into());
270        self
271    }
272
273    /// Custom EFI firmware (`--firmware`).
274    pub fn firmware(mut self, firmware: impl Into<String>) -> Self {
275        self.opts.firmware = Some(firmware.into());
276        self
277    }
278
279    /// Re-fetch the image even if cached (`--force`).
280    pub fn force(mut self) -> Self {
281        self.opts.force = true;
282        self
283    }
284}
285
286impl NetbsdBuilder {
287    common_setters!();
288    disk_setters!();
289
290    /// NetBSD release to boot (`--version`).
291    pub fn version(mut self, version: impl Into<String>) -> Self {
292        self.opts.version = Some(version.into());
293        self
294    }
295
296    /// Re-fetch the image even if cached (`--force`).
297    pub fn force(mut self) -> Self {
298        self.opts.force = true;
299        self
300    }
301}
302
303impl FirmwareBuilder {
304    common_setters!();
305    disk_setters!();
306}
307
308impl KernelBuilder {
309    common_setters!();
310    disk_setters!();
311
312    /// Kernel image format (`--format`, e.g. `"elf"`).
313    pub fn format(mut self, format: impl Into<String>) -> Self {
314        self.opts.format = Some(format.into());
315        self
316    }
317
318    /// Initramfs image path (`--initramfs` takes a value for direct-kernel
319    /// boots, unlike the Linux builder's bare flag).
320    pub fn initramfs(mut self, path: impl Into<String>) -> Self {
321        self.opts.initramfs_path = Some(path.into());
322        self
323    }
324
325    /// Kernel command line (`--cmdline`).
326    pub fn cmdline(mut self, cmdline: impl Into<String>) -> Self {
327        self.opts.cmdline = Some(cmdline.into());
328        self
329    }
330
331    /// Root disk (`--disk`).
332    pub fn disk(mut self, disk: impl Into<String>) -> Self {
333        self.opts.disk = Some(disk.into());
334        self
335    }
336}
337
338impl NanosBuilder {
339    common_setters!();
340
341    /// Nanos kernel override (`--kernel`, Linux hosts).
342    pub fn kernel(mut self, kernel: impl Into<String>) -> Self {
343        self.opts.kernel = Some(kernel.into());
344        self
345    }
346
347    /// Kernel command line (`--cmdline`).
348    pub fn cmdline(mut self, cmdline: impl Into<String>) -> Self {
349        self.opts.cmdline = Some(cmdline.into());
350        self
351    }
352
353    /// Keep the root disk across `rm` (`--persist`).
354    pub fn persist(mut self) -> Self {
355        self.opts.persist = true;
356        self
357    }
358}
359
360impl OsvBuilder {
361    common_setters!();
362
363    /// The application to run and its arguments (`--cmdline`, e.g. `"/hello.so"`).
364    pub fn cmdline(mut self, cmdline: impl Into<String>) -> Self {
365        self.opts.cmdline = Some(cmdline.into());
366        self
367    }
368
369    /// Root disk, required on x86_64 (`--disk`).
370    pub fn disk(mut self, disk: impl Into<String>) -> Self {
371        self.opts.disk = Some(disk.into());
372        self
373    }
374
375    /// GIC version, aarch64 only (`--gic`, `"v2"` or `"v3"`).
376    pub fn gic(mut self, gic: impl Into<String>) -> Self {
377        self.opts.gic = Some(gic.into());
378        self
379    }
380
381    /// Keep the root disk across `rm` (`--persist`).
382    pub fn persist(mut self) -> Self {
383        self.opts.persist = true;
384        self
385    }
386}
387
388impl UnikraftBuilder {
389    common_setters!();
390
391    /// Kernel command line; Unikraft hands it to the application as argv
392    /// (`--cmdline`).
393    pub fn cmdline(mut self, cmdline: impl Into<String>) -> Self {
394        self.opts.cmdline = Some(cmdline.into());
395        self
396    }
397
398    /// Initramfs image path (`--initramfs`).
399    pub fn initramfs(mut self, path: impl Into<String>) -> Self {
400        self.opts.initramfs_path = Some(path.into());
401        self
402    }
403
404    /// A virtio-fs share, `"HOST:GUEST"` with an absolute guest path
405    /// (`--mount`, repeatable). Needs a unikernel built for it.
406    pub fn mount(mut self, mount: impl Into<String>) -> Self {
407        self.opts.mounts.push(mount.into());
408        self
409    }
410}
411
412impl Solo5Builder {
413    common_setters!();
414
415    /// Backing file for a declared block device, `"NAME=FILE"` (`--block`,
416    /// repeatable). The `NAME=` may be omitted when the unikernel declares
417    /// exactly one.
418    pub fn block(mut self, block: impl Into<String>) -> Self {
419        self.opts.block.push(block.into());
420        self
421    }
422
423    /// Arguments passed to the unikernel itself, after a literal `--` (e.g.
424    /// MirageOS's `--ipv4=10.0.0.2/24`).
425    pub fn args<I, S>(mut self, args: I) -> Self
426    where
427        I: IntoIterator<Item = S>,
428        S: Into<String>,
429    {
430        self.opts.trailing_args = strvec(args);
431        self
432    }
433}
434
435// -- Sandbox ------------------------------------------------------------------
436
437impl Sandbox {
438    /// Boot an OCI image as a Linux microVM.
439    pub fn linux(image: impl Into<String>) -> LinuxBuilder {
440        LinuxBuilder {
441            kind: Kind::Linux,
442            opts: CreateOpts {
443                image: Some(image.into()),
444                ..Default::default()
445            },
446        }
447    }
448
449    /// Boot FreeBSD (EFI on macOS, PVH on Linux/amd64).
450    pub fn freebsd() -> FreebsdBuilder {
451        FreebsdBuilder {
452            kind: Kind::Freebsd,
453            opts: CreateOpts::default(),
454        }
455    }
456
457    /// Boot NetBSD (direct-kernel boot everywhere).
458    pub fn netbsd() -> NetbsdBuilder {
459        NetbsdBuilder {
460            kind: Kind::Netbsd,
461            opts: CreateOpts::default(),
462        }
463    }
464
465    /// Boot a raw disk through its UEFI loader.
466    pub fn firmware(firmware: impl Into<String>, disk: impl Into<String>) -> FirmwareBuilder {
467        FirmwareBuilder {
468            kind: Kind::Firmware,
469            opts: CreateOpts {
470                firmware: Some(firmware.into()),
471                disk: Some(disk.into()),
472                ..Default::default()
473            },
474        }
475    }
476
477    /// Boot a kernel directly, no bootloader.
478    pub fn kernel(kernel: impl Into<String>) -> KernelBuilder {
479        KernelBuilder {
480            kind: Kind::Kernel,
481            opts: CreateOpts {
482                kernel: Some(kernel.into()),
483                ..Default::default()
484            },
485        }
486    }
487
488    /// Boot a Nanos image (a path, or a bare name in `~/.ops/images`).
489    pub fn nanos(image: impl Into<String>) -> NanosBuilder {
490        NanosBuilder {
491            kind: Kind::Nanos,
492            opts: CreateOpts {
493                image: Some(image.into()),
494                ..Default::default()
495            },
496        }
497    }
498
499    /// Boot an OSv image (an aarch64 `loader.img`, or on x86_64 the loader
500    /// ELF plus a [`OsvBuilder::disk`]).
501    pub fn osv(image: impl Into<String>) -> OsvBuilder {
502        OsvBuilder {
503            kind: Kind::Osv,
504            opts: CreateOpts {
505                image: Some(image.into()),
506                ..Default::default()
507            },
508        }
509    }
510
511    /// Boot a Unikraft unikernel (a kraft project dir or a built image).
512    pub fn unikraft(path: impl Into<String>) -> UnikraftBuilder {
513        UnikraftBuilder {
514            kind: Kind::Unikraft,
515            opts: CreateOpts {
516                path: Some(path.into()),
517                ..Default::default()
518            },
519        }
520    }
521
522    /// Boot a Solo5 (MirageOS) unikernel (a `.hvt` binary or a project dir
523    /// whose `dist/` holds one).
524    pub fn solo5(path: impl Into<String>) -> Solo5Builder {
525        Solo5Builder {
526            kind: Kind::Solo5,
527            opts: CreateOpts {
528                path: Some(path.into()),
529                ..Default::default()
530            },
531        }
532    }
533
534    fn create_with(kind: Kind, opts: CreateOpts) -> Result<Sandbox> {
535        // Default log level 1 (boot diagnostics), unlike every other call:
536        // when a boot fails, the diagnostics are the error message.
537        let log_level = opts.log_level.unwrap_or(1);
538        let args = build_create_args(kind, &opts);
539        let res = run_full(&args, &[], None, log_level)?;
540        if res.exit_code != 0 {
541            return Err(Error::CommandFailed {
542                exit_code: res.exit_code,
543                stdout: res.stdout,
544                stderr: res.stderr,
545                command: "bsdkrun create".to_string(),
546            });
547        }
548
549        // Detached runs print just the machine id on stdout — take the last
550        // line that looks like one.
551        let machine_id = res
552            .stdout
553            .lines()
554            .map(str::trim)
555            .rfind(|line| looks_like_machine_id(line))
556            .map(str::to_string);
557        let Some(id) = machine_id else {
558            return Err(Error::CommandFailed {
559                exit_code: res.exit_code,
560                stdout: res.stdout,
561                stderr: res.stderr,
562                command: "bsdkrun create (no machine id in output)".to_string(),
563            });
564        };
565        let ssh_port = parse_ssh_port(&res.stderr);
566        Ok(Sandbox { id, ssh_port })
567    }
568
569    /// Wrap an already-known machine id without checking it exists.
570    pub fn from_id(id: impl Into<String>) -> Sandbox {
571        Sandbox {
572            id: id.into(),
573            ssh_port: None,
574        }
575    }
576
577    /// Reconnect to an existing machine by id (a unique prefix is enough).
578    pub fn get(id: &str) -> Result<Sandbox> {
579        for info in Self::list(true)? {
580            if info.id == id || info.id.starts_with(id) || info.name == Some(id.to_string()) {
581                return Ok(Sandbox {
582                    id: info.id,
583                    ssh_port: None,
584                });
585            }
586        }
587        Err(Error::SandboxNotFound { id: id.to_string() })
588    }
589
590    /// List machines. `all` includes exited ones (otherwise: running only).
591    pub fn list(all: bool) -> Result<Vec<SandboxInfo>> {
592        let mut args = vec!["ps".to_string(), "--json".to_string()];
593        if all {
594            args.push("--all".to_string());
595        }
596        let res = checked(&args, "bsdkrun ps")?;
597        let raw = if res.stdout.trim().is_empty() {
598            "[]".to_string()
599        } else {
600            res.stdout
601        };
602        let rows: Value = serde_json::from_str(&raw)?;
603        Ok(rows
604            .as_array()
605            .map(|rows| rows.iter().map(SandboxInfo::from_row).collect())
606            .unwrap_or_default())
607    }
608
609    /// The machine's Docker-style short id.
610    pub fn id(&self) -> &str {
611        &self.id
612    }
613
614    /// Host port forwarded to the guest's SSH, if the boot banner reported one.
615    pub fn ssh_port(&self) -> Option<u16> {
616        self.ssh_port
617    }
618
619    // -- commands ----------------------------------------------------------
620
621    /// Start building a guest command: program first, everything else chained.
622    ///
623    /// ```no_run
624    /// # let sandbox = bsdkrun_sdk::Sandbox::from_id("abc123");
625    /// let out = sandbox
626    ///     .command("node")
627    ///     .args(["-e", "console.log(1)"])
628    ///     .env("X", "hi")
629    ///     .cwd("/app")
630    ///     .run()?;
631    /// # Ok::<(), bsdkrun_sdk::Error>(())
632    /// ```
633    pub fn command(&self, program: impl Into<String>) -> CommandBuilder {
634        CommandBuilder {
635            sandbox_id: self.id.clone(),
636            argv: vec![program.into()],
637            env: Vec::new(),
638            cwd: None,
639            stdin: None,
640            tty: false,
641            log_level: 0,
642            stdout: None,
643            stderr: None,
644        }
645    }
646
647    /// Run an argv in the guest through its exec agent — the shorthand for
648    /// [`Sandbox::command`] with no extra options.
649    ///
650    /// A non-zero exit is reported in the [`ExecResult`], not raised; chain
651    /// [`ExecResult::ok_or_err`] to turn it into an error.
652    pub fn exec<I, S>(&self, argv: I) -> Result<ExecResult>
653    where
654        I: IntoIterator<Item = S>,
655        S: Into<String>,
656    {
657        let mut argv = strvec(argv).into_iter();
658        let Some(program) = argv.next() else {
659            return Err(Error::InvalidInput("exec needs a non-empty argv".into()));
660        };
661        let mut builder = self.command(program);
662        builder.argv.extend(argv);
663        builder.run()
664    }
665
666    /// Read the machine's console log.
667    pub fn logs(&self) -> Result<String> {
668        Ok(run_full(&strvec(["logs", self.id.as_str()]), &[], None, 0)?.stdout)
669    }
670
671    /// Read bsdkrun's boot log for the machine.
672    pub fn boot_logs(&self) -> Result<String> {
673        Ok(run_full(&strvec(["logs", "--boot", self.id.as_str()]), &[], None, 0)?.stdout)
674    }
675
676    /// Attach an interactive shell to the machine (inherits the terminal).
677    ///
678    /// Blocks until the shell exits and returns its exit code.
679    pub fn shell(&self) -> Result<i32> {
680        spawn(["shell", self.id.as_str()])
681    }
682
683    // -- inspection --------------------------------------------------------
684
685    /// Fetch this machine's current status row, or `None` if it's gone.
686    pub fn status(&self) -> Result<Option<SandboxInfo>> {
687        Ok(Self::list(true)?
688            .into_iter()
689            .find(|info| info.id == self.id))
690    }
691
692    /// Whether the machine is currently running.
693    pub fn is_running(&self) -> Result<bool> {
694        Ok(self.status()?.map(|info| info.running).unwrap_or(false))
695    }
696
697    // -- lifecycle ---------------------------------------------------------
698
699    /// Stop the machine (BSD: clean power-off; Linux: SIGTERM).
700    pub fn stop(&self) -> Result<()> {
701        checked(&strvec(["stop", self.id.as_str()]), "bsdkrun stop")?;
702        Ok(())
703    }
704
705    /// Restart a stopped machine in place — same id, disk/rootfs, network.
706    pub fn start(&self) -> Result<()> {
707        checked(&strvec(["start", self.id.as_str()]), "bsdkrun start")?;
708        Ok(())
709    }
710
711    /// Remove the machine and its state. `force` stops it first if running.
712    pub fn remove(&self, force: bool) -> Result<()> {
713        let mut args = vec!["rm".to_string()];
714        if force {
715            args.push("--force".to_string());
716        }
717        args.push(self.id.clone());
718        checked(&args, "bsdkrun rm")?;
719        Ok(())
720    }
721
722    /// Change the recorded vCPU / RAM; applies on the next [`Sandbox::start`].
723    ///
724    /// ```no_run
725    /// # let sandbox = bsdkrun_sdk::Sandbox::from_id("abc123");
726    /// sandbox.update().cpus(4).mem(2048).apply()?;
727    /// # Ok::<(), bsdkrun_sdk::Error>(())
728    /// ```
729    pub fn update(&self) -> UpdateBuilder {
730        UpdateBuilder {
731            id: self.id.clone(),
732            cpus: None,
733            mem: None,
734        }
735    }
736
737    /// Join or switch this machine to a global network (next start).
738    pub fn connect_network(&self, network: &str) -> Result<()> {
739        checked(
740            &strvec(["network", "connect", self.id.as_str(), network]),
741            "bsdkrun network connect",
742        )?;
743        Ok(())
744    }
745
746    /// Detach this machine from its network. Applies on the next start.
747    pub fn disconnect_network(&self) -> Result<()> {
748        checked(
749            &strvec(["network", "disconnect", self.id.as_str()]),
750            "bsdkrun network disconnect",
751        )?;
752        Ok(())
753    }
754
755    // -- in-guest agent helpers -------------------------------------------
756
757    /// Install SSH keys in the guest (`ssh setup`, via the agent).
758    ///
759    /// With no key, the CLI installs your local `~/.ssh/*.pub` keys.
760    ///
761    /// ```no_run
762    /// # let sandbox = bsdkrun_sdk::Sandbox::from_id("abc123");
763    /// sandbox.ssh_setup().user("tsiry").key("~/.ssh/work.pub").run()?;
764    /// # Ok::<(), bsdkrun_sdk::Error>(())
765    /// ```
766    pub fn ssh_setup(&self) -> SshSetupBuilder {
767        SshSetupBuilder {
768            id: self.id.clone(),
769            user: None,
770            keys: Vec::new(),
771        }
772    }
773
774    /// Put the guest on your tailnet (`tailscale setup`, via the agent).
775    ///
776    /// ```no_run
777    /// # let sandbox = bsdkrun_sdk::Sandbox::from_id("abc123");
778    /// sandbox.tailscale_up().authkey("tskey-auth-...").hostname("web").run()?;
779    /// # Ok::<(), bsdkrun_sdk::Error>(())
780    /// ```
781    pub fn tailscale_up(&self) -> TailscaleUpBuilder {
782        TailscaleUpBuilder {
783            id: self.id.clone(),
784            authkey: None,
785            hostname: None,
786            extra: Vec::new(),
787        }
788    }
789}
790
791/// A guest command being assembled — see [`Sandbox::command`].
792pub struct CommandBuilder {
793    sandbox_id: String,
794    argv: Vec<String>,
795    env: Vec<(String, String)>,
796    cwd: Option<String>,
797    stdin: Option<Vec<u8>>,
798    tty: bool,
799    log_level: u32,
800    stdout: Option<Box<dyn std::io::Write + Send>>,
801    stderr: Option<Box<dyn std::io::Write + Send>>,
802}
803
804impl CommandBuilder {
805    /// Append one argument.
806    pub fn arg(mut self, arg: impl Into<String>) -> Self {
807        self.argv.push(arg.into());
808        self
809    }
810
811    /// Append arguments.
812    pub fn args<I, S>(mut self, args: I) -> Self
813    where
814        I: IntoIterator<Item = S>,
815        S: Into<String>,
816    {
817        self.argv.extend(strvec(args));
818        self
819    }
820
821    /// Set a per-command environment variable (`-e K=V`).
822    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
823        self.env.push((key.into(), value.into()));
824        self
825    }
826
827    /// Run in a working directory (emulated: `cd`, then exec the real argv).
828    pub fn cwd(mut self, cwd: impl Into<String>) -> Self {
829        self.cwd = Some(cwd.into());
830        self
831    }
832
833    /// Pipe bytes (or a string) to the command's stdin.
834    pub fn stdin(mut self, data: impl AsRef<[u8]>) -> Self {
835        self.stdin = Some(data.as_ref().to_vec());
836        self
837    }
838
839    /// Allocate a PTY (`-t`).
840    pub fn tty(mut self, tty: bool) -> Self {
841        self.tty = tty;
842        self
843    }
844
845    /// bsdkrun's global `--log-level` for this call (default 0 — quiet).
846    pub fn log_level(mut self, level: u32) -> Self {
847        self.log_level = level;
848        self
849    }
850
851    /// Stream stdout to `writer` while retaining it in the returned result.
852    pub fn stdout(mut self, writer: impl std::io::Write + Send + 'static) -> Self {
853        self.stdout = Some(Box::new(writer));
854        self
855    }
856
857    /// Stream stderr to `writer` while retaining it in the returned result.
858    pub fn stderr(mut self, writer: impl std::io::Write + Send + 'static) -> Self {
859        self.stderr = Some(Box::new(writer));
860        self
861    }
862
863    /// Run the command to completion and capture the result.
864    ///
865    /// A non-zero exit is data, not an error — chain
866    /// [`ExecResult::ok_or_err`] when it should be one.
867    pub fn run(self) -> Result<ExecResult> {
868        let mut argv = self.argv;
869        if let Some(cwd) = &self.cwd {
870            // Emulate a working directory: cd, drop it, then exec the real argv.
871            let mut wrapped = strvec([
872                "/bin/sh",
873                "-c",
874                "cd \"$1\" && shift && exec \"$@\"",
875                "sh",
876                cwd.as_str(),
877            ]);
878            wrapped.append(&mut argv);
879            argv = wrapped;
880        }
881
882        let mut cli = vec!["exec".to_string()];
883        if self.tty {
884            cli.push("-t".to_string());
885        }
886        for (key, value) in &self.env {
887            cli.push("-e".to_string());
888            cli.push(format!("{key}={value}"));
889        }
890        cli.push(self.sandbox_id.clone());
891        cli.extend(argv.iter().cloned());
892
893        let res = run_full_stream(
894            &cli,
895            &[],
896            self.stdin.as_deref(),
897            self.log_level,
898            self.stdout,
899            self.stderr,
900        )?;
901        Ok(ExecResult {
902            stdout: res.stdout,
903            stderr: res.stderr,
904            exit_code: res.exit_code,
905            command: format!("exec {}", argv.join(" ")),
906        })
907    }
908}
909
910/// Pending vCPU / RAM changes — see [`Sandbox::update`].
911#[derive(Debug, Clone)]
912pub struct UpdateBuilder {
913    id: String,
914    cpus: Option<u32>,
915    mem: Option<u32>,
916}
917
918impl UpdateBuilder {
919    pub fn cpus(mut self, cpus: u32) -> Self {
920        self.cpus = Some(cpus);
921        self
922    }
923
924    pub fn mem(mut self, mib: u32) -> Self {
925        self.mem = Some(mib);
926        self
927    }
928
929    /// Record the change; it applies on the machine's next start.
930    pub fn apply(self) -> Result<()> {
931        let mut args = strvec(["update", self.id.as_str()]);
932        if let Some(cpus) = self.cpus {
933            args.push("--cpus".to_string());
934            args.push(cpus.to_string());
935        }
936        if let Some(mem) = self.mem {
937            args.push("--mem".to_string());
938            args.push(mem.to_string());
939        }
940        checked(&args, "bsdkrun update")?;
941        Ok(())
942    }
943}
944
945/// An `ssh setup` invocation being assembled — see [`Sandbox::ssh_setup`].
946#[derive(Debug, Clone)]
947pub struct SshSetupBuilder {
948    id: String,
949    user: Option<String>,
950    keys: Vec<String>,
951}
952
953impl SshSetupBuilder {
954    /// The guest user to install keys for.
955    pub fn user(mut self, user: impl Into<String>) -> Self {
956        self.user = Some(user.into());
957        self
958    }
959
960    /// A literal `ssh-...` public key, or a local `.pub` path (repeatable).
961    pub fn key(mut self, key: impl Into<String>) -> Self {
962        self.keys.push(key.into());
963        self
964    }
965
966    /// Run the setup; a non-zero exit is an error here (unlike `exec`),
967    /// because a failed key install has nothing useful to report but failure.
968    pub fn run(self) -> Result<ExecResult> {
969        let mut action = vec!["setup".to_string()];
970        if let Some(user) = &self.user {
971            action.push("--user".to_string());
972            action.push(user.clone());
973        }
974        for key in &self.keys {
975            action.push("--key".to_string());
976            action.push(key.clone());
977        }
978        run_agent("ssh", &self.id, &action, &[])
979    }
980}
981
982/// A `tailscale setup` invocation being assembled — see [`Sandbox::tailscale_up`].
983#[derive(Debug, Clone)]
984pub struct TailscaleUpBuilder {
985    id: String,
986    authkey: Option<String>,
987    hostname: Option<String>,
988    extra: Vec<String>,
989}
990
991impl TailscaleUpBuilder {
992    /// A tailnet auth key — forwarded as the `TS_AUTHKEY` env var, kept off
993    /// the argument list so it never lands in a process listing.
994    pub fn authkey(mut self, authkey: impl Into<String>) -> Self {
995        self.authkey = Some(authkey.into());
996        self
997    }
998
999    /// The machine name on the tailnet.
1000    pub fn hostname(mut self, hostname: impl Into<String>) -> Self {
1001        self.hostname = Some(hostname.into());
1002        self
1003    }
1004
1005    /// Append a raw extra argument to the setup call.
1006    pub fn arg(mut self, arg: impl Into<String>) -> Self {
1007        self.extra.push(arg.into());
1008        self
1009    }
1010
1011    /// Run the setup; a non-zero exit is an error.
1012    pub fn run(self) -> Result<ExecResult> {
1013        let mut action = vec!["setup".to_string()];
1014        if let Some(hostname) = &self.hostname {
1015            action.push("--hostname".to_string());
1016            action.push(hostname.clone());
1017        }
1018        action.extend(self.extra.iter().cloned());
1019        let env: Vec<(String, String)> = self
1020            .authkey
1021            .map(|key| vec![("TS_AUTHKEY".to_string(), key)])
1022            .unwrap_or_default();
1023        run_agent("tailscale", &self.id, &action, &env)
1024    }
1025}
1026
1027fn run_agent(
1028    family: &str,
1029    id: &str,
1030    action: &[String],
1031    env: &[(String, String)],
1032) -> Result<ExecResult> {
1033    let mut args = vec![family.to_string(), id.to_string()];
1034    args.extend(action.iter().cloned());
1035    let res = run_full(&args, env, None, 0)?;
1036    ExecResult {
1037        stdout: res.stdout,
1038        stderr: res.stderr,
1039        exit_code: res.exit_code,
1040        command: format!("{family} {}", action.join(" ")),
1041    }
1042    .ok_or_err()
1043}
1044
1045#[cfg(test)]
1046mod tests {
1047    use super::*;
1048
1049    fn s(items: &[&str]) -> Vec<String> {
1050        items.iter().map(|s| s.to_string()).collect()
1051    }
1052
1053    #[test]
1054    fn linux_minimal() {
1055        assert_eq!(
1056            Sandbox::linux("alpine").to_args(),
1057            s(&["linux", "alpine", "-d"])
1058        );
1059    }
1060
1061    #[test]
1062    fn linux_full() {
1063        let args = Sandbox::linux("ghcr.io/owner/name:tag")
1064            .kernel("vmlinux")
1065            .kernel_version("6.6")
1066            .initramfs()
1067            .volume("web")
1068            .mount("~/project:/src")
1069            .mount("~/data:/data:ro")
1070            .entrypoint("/bin/sh")
1071            .console("hvc0")
1072            .port("8080:80")
1073            .forward(2222, 22)
1074            .network("devnet")
1075            .name("api")
1076            .cpus(2)
1077            .mem(1024)
1078            .command(["node", "server.js"])
1079            .to_args();
1080        assert_eq!(
1081            args,
1082            s(&[
1083                "linux",
1084                "ghcr.io/owner/name:tag",
1085                "-d",
1086                "--kernel",
1087                "vmlinux",
1088                "--kernel-version",
1089                "6.6",
1090                "--initramfs",
1091                "-v",
1092                "web",
1093                "--mount",
1094                "~/project:/src",
1095                "--mount",
1096                "~/data:/data:ro",
1097                "--entrypoint",
1098                "/bin/sh",
1099                "--console",
1100                "hvc0",
1101                "--port",
1102                "8080:80",
1103                "--port",
1104                "2222:22",
1105                "--network",
1106                "devnet",
1107                "--name",
1108                "api",
1109                "--cpus",
1110                "2",
1111                "--mem",
1112                "1024",
1113                "--",
1114                "node",
1115                "server.js",
1116            ])
1117        );
1118    }
1119
1120    #[test]
1121    fn net_disabled_ordering() {
1122        // --no-net, then ports, then --mac, then --network.
1123        let args = Sandbox::linux("alpine")
1124            .no_net()
1125            .port("2222:22")
1126            .mac("de:ad:be:ef:00:01")
1127            .network("devnet")
1128            .to_args();
1129        assert_eq!(
1130            args,
1131            s(&[
1132                "linux",
1133                "alpine",
1134                "-d",
1135                "--no-net",
1136                "--port",
1137                "2222:22",
1138                "--mac",
1139                "de:ad:be:ef:00:01",
1140                "--network",
1141                "devnet",
1142            ])
1143        );
1144    }
1145
1146    #[test]
1147    fn freebsd_full() {
1148        let args = Sandbox::freebsd()
1149            .version("14.3")
1150            .firmware("KRUN_EFI.fd")
1151            .force()
1152            .persist()
1153            .volume("db")
1154            .attach_disk("extra.raw")
1155            .attach_disk("ro.raw:ro")
1156            .mem(2048)
1157            .name("bsd")
1158            .to_args();
1159        assert_eq!(
1160            args,
1161            s(&[
1162                "freebsd",
1163                "-d",
1164                "--version",
1165                "14.3",
1166                "--firmware",
1167                "KRUN_EFI.fd",
1168                "--force",
1169                "--persist",
1170                "-v",
1171                "db",
1172                "--attach-disk",
1173                "extra.raw",
1174                "--attach-disk",
1175                "ro.raw:ro",
1176                "--name",
1177                "bsd",
1178                "--mem",
1179                "2048",
1180            ])
1181        );
1182    }
1183
1184    #[test]
1185    fn netbsd_minimal() {
1186        assert_eq!(
1187            Sandbox::netbsd().version("10.1").volume("db").to_args(),
1188            s(&["netbsd", "-d", "--version", "10.1", "-v", "db"])
1189        );
1190    }
1191
1192    #[test]
1193    fn firmware_positionals() {
1194        assert_eq!(
1195            Sandbox::firmware("KRUN_EFI.fd", "disk.raw").to_args(),
1196            s(&[
1197                "firmware",
1198                "--firmware",
1199                "KRUN_EFI.fd",
1200                "--disk",
1201                "disk.raw",
1202                "-d",
1203            ])
1204        );
1205    }
1206
1207    #[test]
1208    fn kernel_initramfs_takes_a_path() {
1209        // For kernel, initramfs is a path (value), not a bare flag.
1210        let args = Sandbox::kernel("netbsd")
1211            .format("elf")
1212            .initramfs("initrd.img")
1213            .cmdline("root=ld0a")
1214            .disk("root.raw")
1215            .to_args();
1216        assert_eq!(
1217            args,
1218            s(&[
1219                "kernel",
1220                "--kernel",
1221                "netbsd",
1222                "-d",
1223                "--format",
1224                "elf",
1225                "--initramfs",
1226                "initrd.img",
1227                "--cmdline",
1228                "root=ld0a",
1229                "--disk",
1230                "root.raw",
1231            ])
1232        );
1233    }
1234
1235    #[test]
1236    fn nanos_image_goes_last() {
1237        let args = Sandbox::nanos("hello")
1238            .cmdline("x=1")
1239            .persist()
1240            .mem(512)
1241            .to_args();
1242        assert_eq!(
1243            args,
1244            s(&[
1245                "nanos",
1246                "-d",
1247                "--cmdline",
1248                "x=1",
1249                "--persist",
1250                "--mem",
1251                "512",
1252                "hello",
1253            ])
1254        );
1255    }
1256
1257    #[test]
1258    fn osv_image_goes_last() {
1259        let args = Sandbox::osv("loader.img")
1260            .disk("root.raw")
1261            .gic("v2")
1262            .to_args();
1263        assert_eq!(
1264            args,
1265            s(&[
1266                "osv",
1267                "-d",
1268                "--disk",
1269                "root.raw",
1270                "--gic",
1271                "v2",
1272                "loader.img",
1273            ])
1274        );
1275    }
1276
1277    #[test]
1278    fn unikraft_defaults_path() {
1279        assert_eq!(
1280            Sandbox::unikraft(".").cmdline("helloworld").to_args(),
1281            s(&["unikraft", "-d", "--cmdline", "helloworld", "."])
1282        );
1283    }
1284
1285    #[test]
1286    fn solo5_trailing_args_after_separator() {
1287        let args = Sandbox::solo5("dist/hello.hvt")
1288            .block("storage=disk.img")
1289            .args(["--ipv4=10.0.0.2/24"])
1290            .to_args();
1291        assert_eq!(
1292            args,
1293            s(&[
1294                "solo5",
1295                "-d",
1296                "--block",
1297                "storage=disk.img",
1298                "dist/hello.hvt",
1299                "--",
1300                "--ipv4=10.0.0.2/24",
1301            ])
1302        );
1303    }
1304
1305    #[test]
1306    fn machine_id_lines_are_recognized() {
1307        assert!(looks_like_machine_id("fab8f81e4f91"));
1308        assert!(looks_like_machine_id("abc123"));
1309        assert!(!looks_like_machine_id("abc12")); // too short
1310        assert!(!looks_like_machine_id("pulling alpine:3.20"));
1311        assert!(!looks_like_machine_id("FAB8F81E4F91")); // ids are lowercase
1312        assert!(!looks_like_machine_id(""));
1313    }
1314
1315    #[test]
1316    fn ssh_port_is_read_from_the_banner() {
1317        assert_eq!(
1318            parse_ssh_port("  connect with: ssh -p 2222 root@localhost\n"),
1319            Some(2222)
1320        );
1321        assert_eq!(parse_ssh_port("no banner here"), None);
1322    }
1323}