An Elixir SDK for bsdkrun — a Firecracker-style microVM launcher for BSD and Linux guests on macOS and Linux, built on libkrun. Boot and drive microVMs programmatically, inspired by the Vercel and Deno Sandbox SDKs.
The SDK shells out to the bsdkrun binary via System.cmd/3, so its only
runtime dependency is jason for JSON parsing.
{:ok, box} = Bsdkrun.create(os: :linux, image: "alpine")
# argv exec — no shell parsing; env / stdin / a PTY / a working dir:
{:ok, res} = Bsdkrun.exec(box, ["uname", "-a"])
IO.puts(Bsdkrun.Types.Result.text(res))
{:ok, _} = Bsdkrun.exec(box, ["apk", "add", "curl"])
:ok = Bsdkrun.stop(box)Install
Add :bsdkrun_ex to your mix.exs deps:
def deps do
[
{:bsdkrun_ex, "~> 0.1.0"}
]
endThen mix deps.get.
The Hex package is
bsdkrun_ex— the Gleam SDK already publishes asbsdkrun, and Hex is one namespace shared by both. The modules are plainBsdkrun.*, so nothing in the code below carries the suffix.
The bsdkrun binary
You also need the bsdkrun binary itself (and a linked libkrun — see the
bsdkrun README). The SDK finds the binary via, in order:
Bsdkrun.Binary.set_binary_path("/path/to/bsdkrun")- the
BSDKRUN_BINenvironment variable bsdkrunon yourPATH- an in-repo
target/release/bsdkrunortarget/debug/bsdkrunbuild
Creating a sandbox
Bsdkrun.Sandbox.create/1 is discriminated on :os — the options change per
guest kind. Every create runs the machine detached and returns a
%Bsdkrun.Sandbox{} handle.
# Linux OCI image (docker run-style)
Bsdkrun.create(
os: :linux,
image: "ghcr.io/owner/name:tag",
cpus: 2,
mem: 1024,
volume: "web", # persistent CoW rootfs
mounts: ["~/project:/src", "~/data:/data:ro"],
net: %{ports: ["8080:80", "2222:22"]},
command: ["node", "server.js"] # args after `--`
)
# FreeBSD (EFI on macOS, PVH on Linux/amd64)
Bsdkrun.create(os: :freebsd, version: "14.3", mem: 2048)
# NetBSD (direct-kernel boot everywhere)
Bsdkrun.create(os: :netbsd, version: "10.1", volume: "db")
# Boot a raw disk through its UEFI loader
Bsdkrun.create(os: :firmware, firmware: "KRUN_EFI.fd", disk: "disk.raw")
# Boot a kernel directly, no bootloader
Bsdkrun.create(os: :kernel, kernel: "netbsd", format: "elf", disk: "root.raw")Running commands
exec/3 is the primary programmatic entrypoint. Pass an argv list (no shell
parsing) or a bare program name with :args, plus options:
Bsdkrun.exec(box, ["ls", "-la", "/etc"])
{:ok, res} =
Bsdkrun.exec(box, "node",
args: ["-e", "IO.puts System.get_env(\"X\")"],
env: %{"X" => "hi"},
cwd: "/app",
stdin: "data on stdin",
tty: true, # allocate a PTY
throw_on_error: true # return {:error, _} on a non-zero exit
)
res.stdout
res.exit_code
Bsdkrun.Types.Result.ok?(res)
Bsdkrun.Types.Result.text(res) # stdout, trailing newlines trimmedLifecycle & inventory
{:ok, box} = Bsdkrun.create(os: :linux, image: "alpine", command: ["sleep", "300"])
{:ok, same} = Bsdkrun.get(box.id) # reconnect (prefix ok)
{:ok, list} = Bsdkrun.list(all: true) # [%Bsdkrun.Types.SandboxInfo{}]
Bsdkrun.Sandbox.status(box) # {:ok, %SandboxInfo{} | nil}
Bsdkrun.Sandbox.running?(box) # boolean
Bsdkrun.logs(box) # {:ok, console_log}
Bsdkrun.stop(box) # BSD guests clean-poweroff; Linux SIGTERM
Bsdkrun.start(box) # restart in place — resumes disk/rootfs
Bsdkrun.Sandbox.update(box, cpus: 4, mem: 2048) # applies on next start
Bsdkrun.remove(box, force: true)Host-level modules:
Bsdkrun.System.probe() # toolchain sanity check -> boolean
Bsdkrun.Images.list() # {:ok, [ImageInfo]}
Bsdkrun.Volumes.list() # {:ok, [VolumeInfo]}
Bsdkrun.Volumes.remove("web", force: true)
Bsdkrun.Networks.list() # {:ok, [NetworkInfo]}
Bsdkrun.System.fetch_image(:freebsd, version: "14.3")
Bsdkrun.System.versions(:netbsd)Global networks — reach machines by name
Opt machines into a shared network so they get distinct IPs on one subnet and reach each other by IP and by name (docker-compose style), with internal DNS:
alias Bsdkrun.{Sandbox, Networks}
:ok = Networks.create("devnet")
{:ok, db} =
Sandbox.create(os: :linux, image: "postgres", name: "db", net: %{network: "devnet"})
{:ok, api} =
Sandbox.create(os: :linux, image: "myapi", name: "api", net: %{network: "devnet"})
# resolves db -> its IP on devnet
Sandbox.exec(api, ["ping", "-c1", "db"])
# inspect + manage
{:ok, _networks} = Networks.list() # [%NetworkInfo{}]
{:ok, _members} = Networks.members("devnet") # [%SandboxInfo{}] on the network
{:ok, info} = Sandbox.status(db) # info.network == "devnet", info.net_ip set
# edit membership (applies on next start — a VM's NIC is fixed at boot)
:ok = Sandbox.connect_network(api, "devnet") # or Networks.connect(api.id, "devnet")
:ok = Sandbox.disconnect_network(api)
:ok = Sandbox.start(api) # re-joins with the new membership
:ok = Networks.sync("devnet") # refresh members' /etc/hosts (fixes NetBSD)
:ok = Networks.remove("devnet", force: true)Names resolve on Linux and FreeBSD via the network's DNS; NetBSD resolves via
a synced /etc/hosts block — joins auto-sync, and Networks.sync/1 refreshes an
existing network without restarting members.
Errors
Every fallible function returns {:ok, value} or {:error, %Bsdkrun.Error{}}.
The %Bsdkrun.Error{} exception has a :kind:
:binary_not_found— thebsdkrunbinary wasn't found.:command_failed— a command exited non-zero (carries:exit_code,:stdout,:stderr,:label).:sandbox_not_found—Bsdkrun.get/1matched no machine.
The bang variants (Bsdkrun.create!/1, Bsdkrun.Sandbox.get!/1,
Bsdkrun.Sandbox.list!/1) unwrap the value or raise the error.
Try it interactively
iex -S mix
A .iex.exs in this directory aliases the SDK's modules and defines ps/0
(every machine, exited ones included) and last/0 (the newest one), so the API
is in scope at the prompt. To drive a locally built binary for the session:
BSDKRUN_BIN=../../target/release/bsdkrun iex -S mix
License
MIT