bsdkrun_ex (Elixir SDK)

An Elixir SDK for bsdkrun — a Firecracker-style microVM launcher for BSD, Linux, and unikernel 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)

Or, with the bang variants, as one |> chain:

Bsdkrun.create!(os: :linux, image: "alpine")
|> Bsdkrun.exec!(["uname", "-a"])
|> Bsdkrun.Types.Result.text()
|> IO.puts()

Install

Add :bsdkrun_ex to your mix.exs deps:

def deps do
[
{:bsdkrun_ex, "~> 0.1.0"}
]
end

Then mix deps.get.

The Hex package is bsdkrun_ex — the Gleam SDK already publishes as bsdkrun, and Hex is one namespace shared by both. The modules are plain Bsdkrun.*, 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:

  1. Bsdkrun.Binary.set_binary_path("/path/to/bsdkrun")
  2. the BSDKRUN_BIN environment variable
  3. bsdkrun on your PATH
  4. an in-repo target/release/bsdkrun or target/debug/bsdkrun build

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",
on_stdout: &IO.binwrite(:stdio, &1),
on_stderr: &IO.binwrite(:stderr, &1),
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 trimmed

The callbacks receive binary chunks in real time while the complete streams remain buffered in the returned result. They are independent of :tty; a PTY changes command semantics and may merge stderr into stdout.

Lifecycle & 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)

SandboxInfo.kind is an atom — :linux, :freebsd, :netbsd, :firmware, :kernel, :unikraft, :solo5, :nanos, or :osv — the same vocabulary create/1 takes for :os, so you can match on it directly: case info.kind do :freebsd -> ...; :netbsd -> ...; _ -> ... end.

Pipe-friendly / chainable

Every Bsdkrun.Sandbox function has a bang (!) counterpart that unwraps {:ok, value} or raises Bsdkrun.Error. The lifecycle ones — stop!/1, start!/1, remove!/2, update!/2, connect_network!/2, disconnect_network!/1 — return ref itself (not :ok), so they chain:

Bsdkrun.create!(os: :linux, image: "alpine")
|> Bsdkrun.exec!(["apk", "add", "curl"])
|> Bsdkrun.stop!()

An already-created machine can be joined to (or dropped from) a network the same way — Sandbox.connect_network!/2 and Sandbox.disconnect_network!/1 also return ref, so a network hop is one more link in the chain (it takes effect on the next start!/1, same as connect_network/2):

Bsdkrun.create!(os: :linux, image: "alpine")
|> Bsdkrun.Sandbox.connect_network!("devnet")
|> Bsdkrun.start!()

A volume, on the other hand, only ever gets attached at boot — the bsdkrun CLI has no "attach to a running VM" for it (same for mounts, ports, and extra disks). Bsdkrun.Sandbox.new/1 plus with_*/2 build up those create/1 options by pipe instead, so attaching a volume still reads as one chain — it just ends at create!/1 rather than starting from it:

Bsdkrun.Sandbox.new(os: :linux, image: "alpine")
|> Bsdkrun.Sandbox.with_volume("web")
|> Bsdkrun.Sandbox.with_network("devnet")
|> Bsdkrun.Sandbox.with_port("8080:80")
|> Bsdkrun.Sandbox.create!()
|> Bsdkrun.exec!(["uname", "-a"])

Nothing is sent to bsdkrun until create/1/create!/1 runs. Besides with_volume/2, with_network/2 and with_port(s)/2 above, there's with_mount(s)/2, with_disk/2, with_cpus/2, with_mem/2, with_name/2, with_command/2, and with_opt/3 as an escape hatch for any other create/1 option.

exec!/3, logs!/2, status!/1, Sandbox.ssh_setup!/2 and Sandbox.tailscale_up!/2 return their unwrapped value instead (a Result, a log string, ...) since that's the point of calling them — chain into Bsdkrun.Types.Result from there, or use tap/2 to run one mid-pipeline without losing the sandbox:

Bsdkrun.create!(os: :linux, image: "alpine")
|> tap(&(Bsdkrun.exec!(&1, ["uname", "-a"]) |> Bsdkrun.Types.Result.text() |> IO.puts()))
|> Bsdkrun.stop!()

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.

Connecting to a remote daemon

Everything above talks to a local bsdkrun binary. Bsdkrun.Client is the network sibling: it drives the same operations against a remote bsdkrund over its GraphQL API — no local binary needed, just a URL and a bearer token.

client = Bsdkrun.Client.from_env!() # BSDKRUN_URL / BSDKRUN_TOKEN
# or: client = Bsdkrun.Client.new(url: "http://vps.example.com:50052", token: "9f2c...")
{:ok, machines} = Bsdkrun.Client.list(client, true) # same SandboxInfo Bsdkrun.list returns
{:ok, id} = Bsdkrun.Client.run_linux(client, image: "alpine", cpus: 2, mem: 1024, command: ["sleep", "300"])
{:ok, result} = Bsdkrun.Client.exec(client, id, ["uname", "-a"])
IO.puts(result.output)
Bsdkrun.Client.stop(client, id)
Bsdkrun.Client.remove(client, [id])

Client.run_linux/run_bsd/run_nanos/run_unikraft/run_solo5/run_osv/run_flavor each take the same options (a keyword list or map) as the corresponding GraphQL mutation (daemon/src/graphql.rs) — run_bsd(client, os: :freebsd, ...), etc. — and return the new machine's id. stop/start/remove/update/ commit return a CommandResult (exit_code, stdout, stderr).

For a live terminal instead of a one-shot exec, use shell:

{:ok, session} = Bsdkrun.Client.shell(client, id) # or shell(client, id, command: [...])
Bsdkrun.Client.Shell.write(session, "ls -la\n")
Bsdkrun.Client.Shell.resize(session, 50, 120)
receive do
{:bsdkrun_shell, _id, {:data, bytes}} -> IO.write(bytes)
{:bsdkrun_shell, _id, {:exit, code}} -> IO.puts("exited #{code}")
end
Bsdkrun.Client.Shell.close(session)

Live output (from shell, follow_logs, and the raw subscribe escape hatch) delivers either as {:bsdkrun_shell, id, event}-style messages to the calling process, or to an opts[:on_data] callback — your choice. Both exec/shell and follow_logs are built on the same openShell/ shellOutput shell-session protocol the daemon uses for every interactive terminal — see daemon/README.md for the wire-level story.

Not every GraphQL operation has a typed function yet (flavor/network/volume management, for instance) — Client.request(client, query, variables) runs any raw query or mutation, and Client.subscribe(client, query, variables) runs any raw subscription, for anything not wrapped above.

Like the rest of this SDK, the remote client adds no new dependency — jason (already a dependency for --json parsing) is the only one. HTTP runs over Erlang/OTP's built-in :httpc, and subscriptions (used by exec/ shell/follow_logs) run over a hand-rolled graphql-transport-ws client on :gen_tcp/:ssl, all part of the standard Erlang distribution — plus a small supervision tree (Bsdkrun.Application, an internal, undocumented app module) giving each Client's shared socket somewhere to live.

Client.new/1/from_env/0 both reject a URL configured without a token rather than silently making an unauthenticated request — set both BSDKRUN_URL and BSDKRUN_TOKEN, or pass both explicitly.

Errors

Every fallible function returns {:ok, value} or {:error, %Bsdkrun.Error{}}. The %Bsdkrun.Error{} exception has a :kind:

The bang variants (Bsdkrun.create!/1, Bsdkrun.Sandbox.get!/1, Bsdkrun.Sandbox.list!/1, Bsdkrun.exec!/3, Bsdkrun.stop!/1, ...) unwrap the value or raise the error — see Pipe-friendly / chainable above for how the lifecycle ones return ref for chaining.

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