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