bsdkrun 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +218 -0
- data/lib/bsdkrun/args.rb +125 -0
- data/lib/bsdkrun/binary.rb +103 -0
- data/lib/bsdkrun/errors.rb +52 -0
- data/lib/bsdkrun/images.rb +18 -0
- data/lib/bsdkrun/networks.rb +71 -0
- data/lib/bsdkrun/process.rb +66 -0
- data/lib/bsdkrun/sandbox.rb +265 -0
- data/lib/bsdkrun/system.rb +47 -0
- data/lib/bsdkrun/types.rb +144 -0
- data/lib/bsdkrun/version.rb +6 -0
- data/lib/bsdkrun/volumes.rb +31 -0
- data/lib/bsdkrun.rb +88 -0
- metadata +88 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f3cc7fd1dcbcb90243855217c4fe7852f6e98baf235fc6e0875ca3f7aeafeb7b
|
|
4
|
+
data.tar.gz: 92aaa16edb2d6970912f64840835dbd78611cf9f30bc4c5830c1c4f0f537fa71
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 1505af9b58a1397e6de294f06d76b2a8ca81ae0a46ba80f61c12baa6e5de99d8e9fba3c973bee7157c663233e575468bbe175e435fe23a01258855a4278a00fa
|
|
7
|
+
data.tar.gz: 38b533ac4692529528ce0c5c5739b1f62f8012f7ec6a2ac2814f866c99d7ec47a13ab26f1639e102012ca4e8161483cfe0731c8d987c23432da42133dfc61167
|
data/README.md
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# bsdkrun (Ruby SDK)
|
|
2
|
+
|
|
3
|
+
A Ruby SDK for [**bsdkrun**](https://github.com/tsirysndr/bsdkrun) — a
|
|
4
|
+
Firecracker-style microVM launcher for **BSD and Linux** guests on macOS and
|
|
5
|
+
Linux, built on [libkrun](https://github.com/containers/libkrun). Boot and drive
|
|
6
|
+
microVMs programmatically, inspired by the **Vercel** and **Deno** Sandbox SDKs.
|
|
7
|
+
|
|
8
|
+
The SDK shells out to the `bsdkrun` binary, so it has **zero runtime
|
|
9
|
+
dependencies** — just the Ruby standard library (`open3`, `json`, `pathname`).
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
require "bsdkrun"
|
|
13
|
+
|
|
14
|
+
box = Bsdkrun::Sandbox.create(os: "linux", image: "alpine")
|
|
15
|
+
|
|
16
|
+
# exec argv directly, with env / stdin / a PTY / a working dir:
|
|
17
|
+
puts box.exec(["uname", "-a"]).text
|
|
18
|
+
box.exec(["apk", "add", "curl"], throw_on_error: true)
|
|
19
|
+
box.run_command("curl", ["-fsSL", "https://example.com"])
|
|
20
|
+
|
|
21
|
+
box.stop
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
gem install bsdkrun
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Or in a `Gemfile`:
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
gem "bsdkrun"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### The `bsdkrun` binary
|
|
37
|
+
|
|
38
|
+
You need the `bsdkrun` binary itself. The SDK finds it via, in order:
|
|
39
|
+
|
|
40
|
+
1. `Bsdkrun.binary_path = "/path/to/bsdkrun"`
|
|
41
|
+
2. the `BSDKRUN_BIN` environment variable
|
|
42
|
+
3. `bsdkrun` on your `PATH`
|
|
43
|
+
4. an in-repo `target/release/bsdkrun` or `target/debug/bsdkrun` build
|
|
44
|
+
|
|
45
|
+
See the [bsdkrun README](../../README.md) for installing the binary (Homebrew on
|
|
46
|
+
macOS, or build from source on Linux/KVM). This SDK assumes libkrun is already
|
|
47
|
+
linked — it does not auto-provision it.
|
|
48
|
+
|
|
49
|
+
## Creating a sandbox
|
|
50
|
+
|
|
51
|
+
`Sandbox.create` is discriminated on `os:` — the options change per guest kind.
|
|
52
|
+
Pass keyword arguments or a Hash.
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
# Linux OCI image (docker run-style)
|
|
56
|
+
Bsdkrun::Sandbox.create(
|
|
57
|
+
os: "linux",
|
|
58
|
+
image: "ghcr.io/owner/name:tag",
|
|
59
|
+
cpus: 2,
|
|
60
|
+
mem: 1024,
|
|
61
|
+
volume: "web", # persistent CoW rootfs
|
|
62
|
+
mounts: ["~/project:/src", "~/data:/data:ro"],
|
|
63
|
+
net: { ports: ["8080:80", "2222:22"] },
|
|
64
|
+
command: ["node", "server.js"] # args after `--`
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# FreeBSD (EFI on macOS, PVH on Linux/amd64)
|
|
68
|
+
Bsdkrun::Sandbox.create(os: "freebsd", version: "14.3", mem: 2048)
|
|
69
|
+
|
|
70
|
+
# NetBSD (direct-kernel boot everywhere)
|
|
71
|
+
Bsdkrun::Sandbox.create(os: "netbsd", version: "10.1", volume: "db")
|
|
72
|
+
|
|
73
|
+
# Boot a raw disk through its UEFI loader
|
|
74
|
+
Bsdkrun::Sandbox.create(os: "firmware", firmware: "KRUN_EFI.fd", disk: "disk.raw")
|
|
75
|
+
|
|
76
|
+
# Boot a kernel directly, no bootloader
|
|
77
|
+
Bsdkrun::Sandbox.create(os: "kernel", kernel: "netbsd", format: "elf", disk: "root.raw")
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Every `create` runs the machine **detached** and returns a `Sandbox` handle.
|
|
81
|
+
|
|
82
|
+
## Running commands
|
|
83
|
+
|
|
84
|
+
`exec` is the primary programmatic entrypoint. No shell parsing — pass an argv
|
|
85
|
+
array (or a program name plus `args:`).
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
box.exec(["ls", "-la", "/etc"])
|
|
89
|
+
|
|
90
|
+
box.exec("ruby",
|
|
91
|
+
args: ["-e", "puts ENV['X']"],
|
|
92
|
+
env: { "X" => "hi" },
|
|
93
|
+
cwd: "/app",
|
|
94
|
+
stdin: "data on stdin",
|
|
95
|
+
tty: true, # allocate a PTY
|
|
96
|
+
throw_on_error: true) # raise on non-zero exit (default: false)
|
|
97
|
+
|
|
98
|
+
# Vercel-Sandbox-style alias:
|
|
99
|
+
result = box.run_command("uname", ["-a"])
|
|
100
|
+
result.stdout # raw stdout
|
|
101
|
+
result.text # stdout, trailing newlines trimmed
|
|
102
|
+
result.exit_code
|
|
103
|
+
result.ok? # true on exit 0
|
|
104
|
+
result.lines # non-empty stdout lines
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`exec` returns a `Bsdkrun::Result`. It only raises `CommandFailed` when you pass
|
|
108
|
+
`throw_on_error: true` (or call `result.throw_if_failed!`).
|
|
109
|
+
|
|
110
|
+
## Lifecycle & inventory
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
box = Bsdkrun::Sandbox.create(os: "linux", image: "alpine", command: ["sleep", "300"])
|
|
114
|
+
same = Bsdkrun::Sandbox.get(box.id) # reconnect (prefix ok)
|
|
115
|
+
all = Bsdkrun::Sandbox.list(all: true) # Array<SandboxInfo>
|
|
116
|
+
|
|
117
|
+
box.status # SandboxInfo | nil
|
|
118
|
+
box.running? # true / false
|
|
119
|
+
box.logs # console log (String)
|
|
120
|
+
box.shell # interactive shell (inherits the terminal)
|
|
121
|
+
box.stop # BSD guests clean-poweroff; Linux SIGTERM
|
|
122
|
+
box.start # restart in place — resumes its own disk/rootfs (data persists)
|
|
123
|
+
box.update(cpus: 4, mem: 2048) # applies on next start
|
|
124
|
+
box.remove(force: true)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Host-level namespaces:
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
Bsdkrun::System.probe # toolchain sanity check -> Boolean
|
|
131
|
+
Bsdkrun::Images.list # Array<ImageInfo>
|
|
132
|
+
Bsdkrun::Volumes.list # Array<VolumeInfo>
|
|
133
|
+
Bsdkrun::Volumes.remove("web", force: true)
|
|
134
|
+
Bsdkrun::Networks.list # Array<NetworkInfo>
|
|
135
|
+
Bsdkrun::System.fetch_image("freebsd", version: "14.3")
|
|
136
|
+
Bsdkrun::System.versions("netbsd") # Array<String>
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Networking, SSH & Tailscale
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
# forward ports at create time
|
|
143
|
+
Bsdkrun::Sandbox.create(os: "linux", image: "alpine", net: { ports: ["2222:22"] })
|
|
144
|
+
|
|
145
|
+
# agent-managed key-based SSH
|
|
146
|
+
box.ssh_setup # install local ~/.ssh/*.pub keys
|
|
147
|
+
box.ssh_setup(user: "tsiry", key: "~/.ssh/work.pub")
|
|
148
|
+
|
|
149
|
+
# put a guest on your tailnet
|
|
150
|
+
box.tailscale_up(authkey: "tskey-auth-...", hostname: "web")
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Global networks — reach machines by name
|
|
154
|
+
|
|
155
|
+
Opt machines into a **shared network** so they get distinct IPs on one subnet
|
|
156
|
+
and reach each other **by IP and by name** (docker-compose style), with internal
|
|
157
|
+
DNS:
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
require "bsdkrun"
|
|
161
|
+
|
|
162
|
+
Bsdkrun::Networks.create("devnet")
|
|
163
|
+
|
|
164
|
+
db = Bsdkrun::Sandbox.create(
|
|
165
|
+
os: "linux", image: "alpine", name: "db",
|
|
166
|
+
net: { network: "devnet" }, command: ["sleep", "3600"]
|
|
167
|
+
)
|
|
168
|
+
api = Bsdkrun::Sandbox.create(
|
|
169
|
+
os: "linux", image: "alpine", name: "api",
|
|
170
|
+
net: { network: "devnet" }, command: ["sleep", "3600"]
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# api reaches db by name over the shared subnet
|
|
174
|
+
api.exec(["ping", "-c1", "db"], throw_on_error: true)
|
|
175
|
+
|
|
176
|
+
# inspect + manage
|
|
177
|
+
Bsdkrun::Networks.list # Array<NetworkInfo>
|
|
178
|
+
Bsdkrun::Networks.members("devnet") # Array<SandboxInfo> on the network
|
|
179
|
+
info = db.status # info.network == "devnet", info.net_ip set
|
|
180
|
+
|
|
181
|
+
# edit membership (applies on next start — a VM's NIC is fixed at boot)
|
|
182
|
+
api.connect_network("devnet") # or Bsdkrun::Networks.connect(api.id, "devnet")
|
|
183
|
+
api.disconnect_network
|
|
184
|
+
api.start # re-joins with the new membership
|
|
185
|
+
|
|
186
|
+
Bsdkrun::Networks.sync("devnet") # refresh members' /etc/hosts (fixes NetBSD name lookup)
|
|
187
|
+
Bsdkrun::Networks.remove("devnet", force: true)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Names resolve on Linux and FreeBSD via the network's DNS; **NetBSD** resolves
|
|
191
|
+
via a synced `/etc/hosts` block — joins auto-sync, and `Networks.sync` refreshes
|
|
192
|
+
an existing network without restarting members.
|
|
193
|
+
|
|
194
|
+
## Errors
|
|
195
|
+
|
|
196
|
+
All errors extend `Bsdkrun::Error`:
|
|
197
|
+
|
|
198
|
+
- `Bsdkrun::BinaryNotFound` — the `bsdkrun` binary wasn't found.
|
|
199
|
+
- `Bsdkrun::CommandFailed` — a command exited non-zero (carries `exit_code`,
|
|
200
|
+
`stdout`, `stderr`, `command`). Raised by `exec` with `throw_on_error: true`,
|
|
201
|
+
by the lifecycle/namespace helpers, and by the agent helpers.
|
|
202
|
+
- `Bsdkrun::SandboxNotFound` — `Sandbox.get` matched no machine.
|
|
203
|
+
|
|
204
|
+
## Try it interactively
|
|
205
|
+
|
|
206
|
+
```sh
|
|
207
|
+
bin/console
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Starts IRB with the SDK preloaded — `Bsdkrun::Sandbox`, the `Bsdkrun.images` /
|
|
211
|
+
`.volumes` / `.networks` / `.system` namespaces, plus `ps` (every machine,
|
|
212
|
+
exited ones included) and `last` (the newest one). Pass
|
|
213
|
+
`--bin ../../target/release/bsdkrun` to drive a locally built binary for the
|
|
214
|
+
session.
|
|
215
|
+
|
|
216
|
+
## License
|
|
217
|
+
|
|
218
|
+
MIT
|
data/lib/bsdkrun/args.rb
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bsdkrun
|
|
4
|
+
# Builds the +bsdkrun+ argv (minus the binary and global flags) for a detached
|
|
5
|
+
# +create+. Every path ends with +-d+ so +create+ yields a handle.
|
|
6
|
+
#
|
|
7
|
+
# Options are a Hash with Symbol keys mirroring the TypeScript SDK's
|
|
8
|
+
# +CreateOptions+, discriminated on +:os+.
|
|
9
|
+
module Args
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Networking flags shared by every guest kind.
|
|
13
|
+
# @param net [Hash, nil]
|
|
14
|
+
# @return [Array<String>]
|
|
15
|
+
def net_args(net)
|
|
16
|
+
a = []
|
|
17
|
+
return a unless net
|
|
18
|
+
|
|
19
|
+
a.push("--no-net") if net[:disabled]
|
|
20
|
+
Array(net[:ports]).each do |p|
|
|
21
|
+
a.push("--port", p.is_a?(String) ? p : "#{p[:host]}:#{p[:guest]}")
|
|
22
|
+
end
|
|
23
|
+
a.push("--mac", net[:mac]) if net[:mac]
|
|
24
|
+
a.push("--network", net[:network]) if net[:network]
|
|
25
|
+
a
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# +--name+ flag if a name is set.
|
|
29
|
+
# @param o [Hash]
|
|
30
|
+
# @return [Array<String>]
|
|
31
|
+
def name_args(o)
|
|
32
|
+
o[:name] ? ["--name", o[:name].to_s] : []
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# +--cpus+ / +--mem+ sizing flags.
|
|
36
|
+
# @param o [Hash]
|
|
37
|
+
# @return [Array<String>]
|
|
38
|
+
def vm_args(o)
|
|
39
|
+
a = []
|
|
40
|
+
a.push("--cpus", o[:cpus].to_s) unless o[:cpus].nil?
|
|
41
|
+
a.push("--mem", o[:mem].to_s) unless o[:mem].nil?
|
|
42
|
+
a
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Disk-persistence flags shared by BSD / firmware / kernel guests.
|
|
46
|
+
# @param o [Hash]
|
|
47
|
+
# @return [Array<String>]
|
|
48
|
+
def disk_args(o)
|
|
49
|
+
a = []
|
|
50
|
+
a.push("--persist") if o[:persist]
|
|
51
|
+
a.push("-v", o[:volume]) if o[:volume]
|
|
52
|
+
Array(o[:attach_disk]).each { |d| a.push("--attach-disk", d) }
|
|
53
|
+
a
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Build the full detached +create+ argv for the given options.
|
|
57
|
+
#
|
|
58
|
+
# @param opts [Hash] create options, discriminated on +:os+.
|
|
59
|
+
# @return [Array<String>]
|
|
60
|
+
# @raise [ArgumentError] on an unknown +:os+.
|
|
61
|
+
def build_create_args(opts)
|
|
62
|
+
case opts[:os].to_s
|
|
63
|
+
when "linux" then linux_args(opts)
|
|
64
|
+
when "freebsd" then freebsd_args(opts)
|
|
65
|
+
when "netbsd" then netbsd_args(opts)
|
|
66
|
+
when "firmware" then firmware_args(opts)
|
|
67
|
+
when "kernel" then kernel_args(opts)
|
|
68
|
+
else raise ArgumentError, "unknown os: #{opts[:os].inspect}"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @!visibility private
|
|
73
|
+
def linux_args(opts)
|
|
74
|
+
a = ["linux", opts.fetch(:image).to_s, "-d"]
|
|
75
|
+
a.push("--kernel", opts[:kernel]) if opts[:kernel]
|
|
76
|
+
a.push("--kernel-version", opts[:kernel_version]) if opts[:kernel_version]
|
|
77
|
+
a.push("--initramfs") if opts[:initramfs]
|
|
78
|
+
a.push("-v", opts[:volume]) if opts[:volume]
|
|
79
|
+
Array(opts[:mounts]).each { |m| a.push("--mount", m) }
|
|
80
|
+
a.push("--entrypoint", opts[:entrypoint]) if opts[:entrypoint]
|
|
81
|
+
a.push("--console", opts[:console]) if opts[:console]
|
|
82
|
+
a.concat(net_args(opts[:net])).concat(name_args(opts)).concat(vm_args(opts))
|
|
83
|
+
cmd = opts[:command]
|
|
84
|
+
a.push("--", *cmd) if cmd && !cmd.empty?
|
|
85
|
+
a
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @!visibility private
|
|
89
|
+
def freebsd_args(opts)
|
|
90
|
+
a = ["freebsd", "-d"]
|
|
91
|
+
a.push("--version", opts[:version].to_s) if opts[:version]
|
|
92
|
+
a.push("--firmware", opts[:firmware]) if opts[:firmware]
|
|
93
|
+
a.push("--force") if opts[:force]
|
|
94
|
+
a.concat(disk_args(opts)).concat(net_args(opts[:net]))
|
|
95
|
+
.concat(name_args(opts)).concat(vm_args(opts))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @!visibility private
|
|
99
|
+
def netbsd_args(opts)
|
|
100
|
+
a = ["netbsd", "-d"]
|
|
101
|
+
a.push("--version", opts[:version].to_s) if opts[:version]
|
|
102
|
+
a.push("--force") if opts[:force]
|
|
103
|
+
a.concat(disk_args(opts)).concat(net_args(opts[:net]))
|
|
104
|
+
.concat(name_args(opts)).concat(vm_args(opts))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @!visibility private
|
|
108
|
+
def firmware_args(opts)
|
|
109
|
+
a = ["firmware", "--firmware", opts.fetch(:firmware), "--disk", opts.fetch(:disk), "-d"]
|
|
110
|
+
a.concat(disk_args(opts)).concat(net_args(opts[:net]))
|
|
111
|
+
.concat(name_args(opts)).concat(vm_args(opts))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @!visibility private
|
|
115
|
+
def kernel_args(opts)
|
|
116
|
+
a = ["kernel", "--kernel", opts.fetch(:kernel), "-d"]
|
|
117
|
+
a.push("--format", opts[:format].to_s) if opts[:format]
|
|
118
|
+
a.push("--initramfs", opts[:initramfs]) if opts[:initramfs]
|
|
119
|
+
a.push("--cmdline", opts[:cmdline]) if opts[:cmdline]
|
|
120
|
+
a.push("--disk", opts[:disk]) if opts[:disk]
|
|
121
|
+
a.concat(disk_args(opts)).concat(net_args(opts[:net]))
|
|
122
|
+
.concat(name_args(opts)).concat(vm_args(opts))
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Bsdkrun
|
|
6
|
+
# Locates the +bsdkrun+ binary on the host and caches the result.
|
|
7
|
+
#
|
|
8
|
+
# Resolution order (first match wins):
|
|
9
|
+
# 1. an explicit override set via {Bsdkrun.binary_path=}
|
|
10
|
+
# 2. the +BSDKRUN_BIN+ environment variable
|
|
11
|
+
# 3. +bsdkrun+ on +PATH+
|
|
12
|
+
# 4. an in-repo dev build: +<repo>/target/release/bsdkrun+ then +debug+
|
|
13
|
+
module Binary
|
|
14
|
+
@override = nil
|
|
15
|
+
@resolved = nil
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
# Force the SDK to use a specific +bsdkrun+ binary, bypassing discovery.
|
|
19
|
+
#
|
|
20
|
+
# @param path [String, nil]
|
|
21
|
+
# @return [void]
|
|
22
|
+
def override=(path)
|
|
23
|
+
@override = path
|
|
24
|
+
@resolved = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [String, nil] the current explicit override, if any.
|
|
28
|
+
attr_reader :override
|
|
29
|
+
|
|
30
|
+
# Reset cached discovery state (mainly for tests).
|
|
31
|
+
# @return [void]
|
|
32
|
+
def reset!
|
|
33
|
+
@resolved = nil
|
|
34
|
+
@override = nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Resolve (and cache) the path to the +bsdkrun+ binary.
|
|
38
|
+
#
|
|
39
|
+
# @return [String] the resolved absolute path or bare command name.
|
|
40
|
+
# @raise [BinaryNotFound] when nothing matched.
|
|
41
|
+
def resolve
|
|
42
|
+
return @resolved if @resolved
|
|
43
|
+
|
|
44
|
+
searched = candidates
|
|
45
|
+
searched.each do |candidate|
|
|
46
|
+
if path_like?(candidate)
|
|
47
|
+
return @resolved = candidate if File.exist?(candidate)
|
|
48
|
+
else
|
|
49
|
+
found = which(candidate)
|
|
50
|
+
return @resolved = found if found
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
raise BinaryNotFound, searched
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def path_like?(candidate)
|
|
59
|
+
candidate.include?(File::SEPARATOR) ||
|
|
60
|
+
(File::ALT_SEPARATOR && candidate.include?(File::ALT_SEPARATOR))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Candidate locations, in priority order.
|
|
64
|
+
def candidates
|
|
65
|
+
out = []
|
|
66
|
+
out << @override if @override
|
|
67
|
+
|
|
68
|
+
env = ENV["BSDKRUN_BIN"]
|
|
69
|
+
out << env if env && !env.empty?
|
|
70
|
+
|
|
71
|
+
# A `bsdkrun` already on PATH wins over in-repo builds.
|
|
72
|
+
on_path = which("bsdkrun")
|
|
73
|
+
out << on_path if on_path
|
|
74
|
+
|
|
75
|
+
# This file lives at sdk/ruby/lib/bsdkrun/binary.rb, so the repo root is
|
|
76
|
+
# four levels up. Prefer a release build, fall back to debug.
|
|
77
|
+
repo_root = File.expand_path("../../../..", __dir__)
|
|
78
|
+
out << File.join(repo_root, "target", "release", "bsdkrun")
|
|
79
|
+
out << File.join(repo_root, "target", "debug", "bsdkrun")
|
|
80
|
+
out
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Cross-platform PATH lookup for an executable.
|
|
84
|
+
def which(name)
|
|
85
|
+
exts =
|
|
86
|
+
if File::ALT_SEPARATOR
|
|
87
|
+
(ENV["PATHEXT"] || ".EXE;.CMD;.BAT").split(";")
|
|
88
|
+
else
|
|
89
|
+
[""]
|
|
90
|
+
end
|
|
91
|
+
(ENV["PATH"] || "").split(File::PATH_SEPARATOR).each do |dir|
|
|
92
|
+
next if dir.empty?
|
|
93
|
+
|
|
94
|
+
exts.each do |ext|
|
|
95
|
+
full = File.join(dir, name + ext)
|
|
96
|
+
return full if File.file?(full) && File.executable?(full)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bsdkrun
|
|
4
|
+
# Base class for every error the SDK raises.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# The +bsdkrun+ binary could not be located on the host.
|
|
8
|
+
class BinaryNotFound < Error
|
|
9
|
+
# @param searched [Array<String>] the paths that were probed, in order.
|
|
10
|
+
def initialize(searched)
|
|
11
|
+
super(
|
|
12
|
+
'could not find the "bsdkrun" binary. Set BSDKRUN_BIN, add it to PATH, ' \
|
|
13
|
+
"or set Bsdkrun.binary_path=. Looked in: #{Array(searched).join(', ')}"
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# A +bsdkrun+ invocation exited non-zero.
|
|
19
|
+
class CommandFailed < Error
|
|
20
|
+
# @return [Integer] the process exit status.
|
|
21
|
+
attr_reader :exit_code
|
|
22
|
+
# @return [String] the captured standard output.
|
|
23
|
+
attr_reader :stdout
|
|
24
|
+
# @return [String] the captured standard error.
|
|
25
|
+
attr_reader :stderr
|
|
26
|
+
# @return [String] a human label for the command that failed.
|
|
27
|
+
attr_reader :command
|
|
28
|
+
|
|
29
|
+
# @param exit_code [Integer]
|
|
30
|
+
# @param stdout [String]
|
|
31
|
+
# @param stderr [String]
|
|
32
|
+
# @param command [String] label describing the invocation.
|
|
33
|
+
def initialize(exit_code:, stdout:, stderr:, command:)
|
|
34
|
+
@exit_code = exit_code
|
|
35
|
+
@stdout = stdout
|
|
36
|
+
@stderr = stderr
|
|
37
|
+
@command = command
|
|
38
|
+
message = "command failed (exit #{exit_code}): #{command}"
|
|
39
|
+
trimmed = stderr.to_s.strip
|
|
40
|
+
message += "\n#{trimmed}" unless trimmed.empty?
|
|
41
|
+
super(message)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# No machine matched the given id / prefix.
|
|
46
|
+
class SandboxNotFound < Error
|
|
47
|
+
# @param id [String] the id or prefix that matched nothing.
|
|
48
|
+
def initialize(id)
|
|
49
|
+
super("no sandbox found matching id #{id.inspect}")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Bsdkrun
|
|
6
|
+
# Host-level image operations.
|
|
7
|
+
module Images
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# List downloaded images (pulled OCI images + fetched BSD images).
|
|
11
|
+
# @return [Array<ImageInfo>]
|
|
12
|
+
def list
|
|
13
|
+
res = Process.run!(["images", "--json"], label: "bsdkrun images")
|
|
14
|
+
rows = JSON.parse(res.stdout.empty? ? "[]" : res.stdout)
|
|
15
|
+
rows.map { |row| ImageInfo.from_row(row) }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Bsdkrun
|
|
6
|
+
# Global-network operations — shared subnets where members reach each other by
|
|
7
|
+
# IP and by name.
|
|
8
|
+
module Networks
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# List global networks and their member counts.
|
|
12
|
+
# @return [Array<NetworkInfo>]
|
|
13
|
+
def list
|
|
14
|
+
res = Process.run!(["network", "ls", "--json"], label: "bsdkrun network ls")
|
|
15
|
+
rows = JSON.parse(res.stdout.empty? ? "[]" : res.stdout)
|
|
16
|
+
rows.map { |row| NetworkInfo.from_row(row) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Create a global network (starts its shared switch).
|
|
20
|
+
# @param name [String]
|
|
21
|
+
# @return [void]
|
|
22
|
+
def create(name)
|
|
23
|
+
Process.run!(["network", "create", name], label: "bsdkrun network create")
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Remove one or more networks. +force+ allows removal with running members.
|
|
28
|
+
# @param names [String, Array<String>]
|
|
29
|
+
# @param force [Boolean]
|
|
30
|
+
# @return [void]
|
|
31
|
+
def remove(names, force: false)
|
|
32
|
+
args = ["network", "rm"]
|
|
33
|
+
args << "--force" if force
|
|
34
|
+
args.concat(Array(names))
|
|
35
|
+
Process.run!(args, label: "bsdkrun network rm")
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Join or switch a machine to a network. Applies on the machine's next start.
|
|
40
|
+
# @param machine [String] id or name.
|
|
41
|
+
# @param network [String]
|
|
42
|
+
# @return [void]
|
|
43
|
+
def connect(machine, network)
|
|
44
|
+
Process.run!(["network", "connect", machine, network], label: "bsdkrun network connect")
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Detach a machine from its network. Applies on its next start.
|
|
49
|
+
# @param machine [String]
|
|
50
|
+
# @return [void]
|
|
51
|
+
def disconnect(machine)
|
|
52
|
+
Process.run!(["network", "disconnect", machine], label: "bsdkrun network disconnect")
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Refresh members' +/etc/hosts+ so peers resolve by name (notably NetBSD).
|
|
57
|
+
# @param network [String]
|
|
58
|
+
# @return [void]
|
|
59
|
+
def sync(network)
|
|
60
|
+
Process.run!(["network", "sync", network], label: "bsdkrun network sync")
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The machines currently attached to +network+ (running or stopped).
|
|
65
|
+
# @param network [String]
|
|
66
|
+
# @return [Array<SandboxInfo>]
|
|
67
|
+
def members(network)
|
|
68
|
+
Sandbox.list(all: true).select { |m| m.network == network }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Bsdkrun
|
|
6
|
+
# Spawns the +bsdkrun+ CLI and captures its output.
|
|
7
|
+
#
|
|
8
|
+
# Every invocation is prefixed with +--log-level+ (default 0) so the SDK's
|
|
9
|
+
# captured output stays clean.
|
|
10
|
+
module Process
|
|
11
|
+
# Captured output of a raw CLI invocation.
|
|
12
|
+
#
|
|
13
|
+
# @!attribute [r] stdout
|
|
14
|
+
# @return [String]
|
|
15
|
+
# @!attribute [r] stderr
|
|
16
|
+
# @return [String]
|
|
17
|
+
# @!attribute [r] exit_code
|
|
18
|
+
# @return [Integer]
|
|
19
|
+
RawResult = Struct.new(:stdout, :stderr, :exit_code, keyword_init: true)
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# Run +bsdkrun --log-level <n> <args>+ to completion, buffering output.
|
|
24
|
+
#
|
|
25
|
+
# @param args [Array<String>] CLI arguments (without the binary).
|
|
26
|
+
# @param env [Hash] extra environment merged onto the process env.
|
|
27
|
+
# @param stdin [String, nil] data piped to the child's stdin.
|
|
28
|
+
# @param log_level [Integer] bsdkrun global log level (0=off .. 5=trace).
|
|
29
|
+
# @return [RawResult]
|
|
30
|
+
def run(args, env: {}, stdin: nil, log_level: 0)
|
|
31
|
+
bin = Binary.resolve
|
|
32
|
+
full = ["--log-level", log_level.to_s, *args]
|
|
33
|
+
merged_env = env.to_h.transform_keys(&:to_s).transform_values(&:to_s)
|
|
34
|
+
out, err, status = Open3.capture3(merged_env, bin, *full, stdin_data: stdin || "")
|
|
35
|
+
RawResult.new(stdout: out, stderr: err, exit_code: status.exitstatus || 0)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Run and raise {CommandFailed} on a non-zero exit.
|
|
39
|
+
#
|
|
40
|
+
# @param args [Array<String>]
|
|
41
|
+
# @param label [String] human label used in the error.
|
|
42
|
+
# @return [RawResult]
|
|
43
|
+
# @raise [CommandFailed]
|
|
44
|
+
def run!(args, label:, **opts)
|
|
45
|
+
res = run(args, **opts)
|
|
46
|
+
unless res.exit_code.zero?
|
|
47
|
+
raise CommandFailed.new(
|
|
48
|
+
exit_code: res.exit_code, stdout: res.stdout, stderr: res.stderr, command: label
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
res
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Spawn an interactive +bsdkrun+ command inheriting the parent's stdio and
|
|
55
|
+
# wait for it (for +shell+). Returns the child's exit status boolean.
|
|
56
|
+
#
|
|
57
|
+
# @param args [Array<String>]
|
|
58
|
+
# @param log_level [Integer]
|
|
59
|
+
# @return [Boolean] true if the command exited zero.
|
|
60
|
+
def spawn_interactive(args, log_level: 0)
|
|
61
|
+
bin = Binary.resolve
|
|
62
|
+
full = ["--log-level", log_level.to_s, *args]
|
|
63
|
+
system(bin, *full)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Bsdkrun
|
|
6
|
+
# A handle to a running (or stopped) bsdkrun microVM.
|
|
7
|
+
#
|
|
8
|
+
# Create one with {Sandbox.create}, reconnect with {Sandbox.get}, or enumerate
|
|
9
|
+
# with {Sandbox.list}.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# box = Bsdkrun::Sandbox.create(os: "linux", image: "alpine")
|
|
13
|
+
# box.exec(["uname", "-a"]).text
|
|
14
|
+
# box.stop
|
|
15
|
+
class Sandbox
|
|
16
|
+
ID_RE = /\A[0-9a-f]{6,}\z/
|
|
17
|
+
SSH_PORT_RE = /ssh -p (\d+)/
|
|
18
|
+
private_constant :ID_RE, :SSH_PORT_RE
|
|
19
|
+
|
|
20
|
+
# The machine's Docker-style short id.
|
|
21
|
+
# @return [String]
|
|
22
|
+
attr_reader :id
|
|
23
|
+
|
|
24
|
+
# Host port forwarded to the guest's SSH, if the boot banner reported one.
|
|
25
|
+
# @return [Integer, nil]
|
|
26
|
+
attr_reader :ssh_port
|
|
27
|
+
|
|
28
|
+
# @param id [String]
|
|
29
|
+
# @param ssh_port [Integer, nil]
|
|
30
|
+
def initialize(id, ssh_port: nil)
|
|
31
|
+
@id = id
|
|
32
|
+
@ssh_port = ssh_port
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
# Boot a new microVM and return a handle to it.
|
|
37
|
+
#
|
|
38
|
+
# Accepts create options as a keyword list or a Hash; discriminated on
|
|
39
|
+
# +:os+ ("linux", "freebsd", "netbsd", "firmware", "kernel").
|
|
40
|
+
#
|
|
41
|
+
# @param opts [Hash]
|
|
42
|
+
# @return [Sandbox]
|
|
43
|
+
# @raise [CommandFailed] if boot fails or no machine id is printed.
|
|
44
|
+
def create(opts = {}, **kwargs)
|
|
45
|
+
opts = normalize(opts.merge(kwargs))
|
|
46
|
+
args = Args.build_create_args(opts)
|
|
47
|
+
res = Process.run(args, log_level: opts.fetch(:log_level, 1))
|
|
48
|
+
if res.exit_code != 0
|
|
49
|
+
raise CommandFailed.new(
|
|
50
|
+
exit_code: res.exit_code, stdout: res.stdout, stderr: res.stderr,
|
|
51
|
+
command: "bsdkrun create"
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
# Detached runs print just the machine id on stdout.
|
|
55
|
+
id = res.stdout.split("\n").map(&:strip).select { |l| l.match?(ID_RE) }.last
|
|
56
|
+
unless id
|
|
57
|
+
raise CommandFailed.new(
|
|
58
|
+
exit_code: res.exit_code, stdout: res.stdout, stderr: res.stderr,
|
|
59
|
+
command: "bsdkrun create (no machine id in output)"
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
match = res.stderr.match(SSH_PORT_RE)
|
|
63
|
+
new(id, ssh_port: match && match[1].to_i)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Reconnect to an existing machine by id (a unique prefix is enough).
|
|
67
|
+
#
|
|
68
|
+
# @param id [String]
|
|
69
|
+
# @return [Sandbox]
|
|
70
|
+
# @raise [SandboxNotFound]
|
|
71
|
+
def get(id)
|
|
72
|
+
row = list(all: true).find { |m| m.id == id || m.id.start_with?(id) }
|
|
73
|
+
raise SandboxNotFound, id unless row
|
|
74
|
+
|
|
75
|
+
new(row.id)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# List machines. +all: true+ includes exited ones (default running only).
|
|
79
|
+
#
|
|
80
|
+
# @param all [Boolean]
|
|
81
|
+
# @return [Array<SandboxInfo>]
|
|
82
|
+
def list(all: false)
|
|
83
|
+
args = ["ps", "--json"]
|
|
84
|
+
args << "--all" if all
|
|
85
|
+
res = Process.run!(args, label: "bsdkrun ps")
|
|
86
|
+
rows = JSON.parse(res.stdout.empty? ? "[]" : res.stdout)
|
|
87
|
+
rows.map { |row| SandboxInfo.from_row(row) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Symbolize keys and normalize a nested +:net+ hash.
|
|
91
|
+
# @!visibility private
|
|
92
|
+
def normalize(opts)
|
|
93
|
+
h = opts.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
94
|
+
h[:net] = h[:net].each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v } if h[:net].is_a?(Hash)
|
|
95
|
+
h
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Run a command in the guest through its exec agent.
|
|
100
|
+
#
|
|
101
|
+
# +command+ may be an Array (argv, no shell parsing) or a String program
|
|
102
|
+
# name; with a String, +args:+ supplies its arguments.
|
|
103
|
+
#
|
|
104
|
+
# @param command [String, Array<String>]
|
|
105
|
+
# @param args [Array<String>] arguments when +command+ is a bare String.
|
|
106
|
+
# @param env [Hash] environment variables (+-e K=V+).
|
|
107
|
+
# @param tty [Boolean] allocate a pseudo-TTY in the guest (+-t+).
|
|
108
|
+
# @param stdin [String, nil] data piped to the command's stdin.
|
|
109
|
+
# @param cwd [String, nil] working directory (emulated via +sh -c 'cd …'+).
|
|
110
|
+
# @param throw_on_error [Boolean] raise {CommandFailed} on a non-zero exit.
|
|
111
|
+
# @param log_level [Integer] per-command bsdkrun log level.
|
|
112
|
+
# @return [Result]
|
|
113
|
+
def exec(command, args: [], env: {}, tty: false, stdin: nil, cwd: nil,
|
|
114
|
+
throw_on_error: false, log_level: 0)
|
|
115
|
+
argv = command.is_a?(Array) ? command.dup : [command, *args]
|
|
116
|
+
|
|
117
|
+
if cwd
|
|
118
|
+
# Emulate a working directory: cd, drop it, then exec the real argv.
|
|
119
|
+
argv = ["/bin/sh", "-c", 'cd "$1" && shift && exec "$@"', "sh", cwd, *argv]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
cli = ["exec"]
|
|
123
|
+
cli << "-t" if tty
|
|
124
|
+
env.each { |k, v| cli.push("-e", "#{k}=#{v}") }
|
|
125
|
+
cli.push(@id, *argv)
|
|
126
|
+
|
|
127
|
+
res = Process.run(cli, stdin: stdin, log_level: log_level)
|
|
128
|
+
result = Result.new(
|
|
129
|
+
stdout: res.stdout, stderr: res.stderr, exit_code: res.exit_code,
|
|
130
|
+
command: "exec #{argv.join(' ')}"
|
|
131
|
+
)
|
|
132
|
+
result.throw_if_failed! if throw_on_error
|
|
133
|
+
result
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Vercel-Sandbox-style alias for {#exec}: a program plus its args.
|
|
137
|
+
#
|
|
138
|
+
# @param command [String]
|
|
139
|
+
# @param args [Array<String>]
|
|
140
|
+
# @return [Result]
|
|
141
|
+
def run_command(command, args = [], **opts)
|
|
142
|
+
exec(command, args: args, **opts)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Read the machine's console log.
|
|
146
|
+
#
|
|
147
|
+
# @param boot [Boolean] show bsdkrun's own boot log instead of the console.
|
|
148
|
+
# @return [String]
|
|
149
|
+
def logs(boot: false)
|
|
150
|
+
args = ["logs"]
|
|
151
|
+
args << "--boot" if boot
|
|
152
|
+
args << @id
|
|
153
|
+
Process.run(args).stdout
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Attach an interactive shell to the machine (inherits the terminal).
|
|
157
|
+
# @return [Boolean] true if the shell exited zero.
|
|
158
|
+
def shell
|
|
159
|
+
Process.spawn_interactive(["shell", @id])
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Fetch this machine's current status row, or nil if it's gone.
|
|
163
|
+
# @return [SandboxInfo, nil]
|
|
164
|
+
def status
|
|
165
|
+
Sandbox.list(all: true).find { |m| m.id == @id }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Whether the machine is currently running.
|
|
169
|
+
# @return [Boolean]
|
|
170
|
+
def running?
|
|
171
|
+
s = status
|
|
172
|
+
s ? s.running : false
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Stop the machine. BSD guests are cleanly powered off; Linux is SIGTERM'd.
|
|
176
|
+
# @return [void]
|
|
177
|
+
def stop
|
|
178
|
+
lifecycle(["stop", @id], "bsdkrun stop")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Restart a stopped machine in place (same id, disk/rootfs). Boots detached.
|
|
182
|
+
# @return [void]
|
|
183
|
+
def start
|
|
184
|
+
lifecycle(["start", @id], "bsdkrun start")
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Remove the machine and its state. +force+ stops it first if running.
|
|
188
|
+
# @param force [Boolean]
|
|
189
|
+
# @return [void]
|
|
190
|
+
def remove(force: false)
|
|
191
|
+
args = ["rm"]
|
|
192
|
+
args << "--force" if force
|
|
193
|
+
args << @id
|
|
194
|
+
lifecycle(args, "bsdkrun rm")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Change the recorded vCPU / RAM. Applies on the next {#start}.
|
|
198
|
+
# @param cpus [Integer, nil]
|
|
199
|
+
# @param mem [Integer, nil]
|
|
200
|
+
# @return [void]
|
|
201
|
+
def update(cpus: nil, mem: nil)
|
|
202
|
+
args = ["update", @id]
|
|
203
|
+
args.push("--cpus", cpus.to_s) unless cpus.nil?
|
|
204
|
+
args.push("--mem", mem.to_s) unless mem.nil?
|
|
205
|
+
lifecycle(args, "bsdkrun update")
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Join or switch this machine to a global network. Applies on next {#start}.
|
|
209
|
+
# @param network [String]
|
|
210
|
+
# @return [void]
|
|
211
|
+
def connect_network(network)
|
|
212
|
+
lifecycle(["network", "connect", @id, network], "bsdkrun network connect")
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Detach this machine from its network. Applies on next {#start}.
|
|
216
|
+
# @return [void]
|
|
217
|
+
def disconnect_network
|
|
218
|
+
lifecycle(["network", "disconnect", @id], "bsdkrun network disconnect")
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Install SSH keys in the guest via the agent (+ssh setup+). With no keys,
|
|
222
|
+
# the CLI installs your local +~/.ssh/*.pub+.
|
|
223
|
+
#
|
|
224
|
+
# @param user [String, nil] target user (default root).
|
|
225
|
+
# @param key [String, Array<String>, nil] literal key(s) or +.pub+ path(s).
|
|
226
|
+
# @return [Result]
|
|
227
|
+
def ssh_setup(user: nil, key: nil)
|
|
228
|
+
action = ["setup"]
|
|
229
|
+
action.push("--user", user) if user
|
|
230
|
+
Array(key).each { |k| action.push("--key", k) }
|
|
231
|
+
agent("ssh", action)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Put the guest on your tailnet (+tailscale setup+).
|
|
235
|
+
#
|
|
236
|
+
# @param authkey [String, nil] tailnet auth key (sent as +TS_AUTHKEY+).
|
|
237
|
+
# @param hostname [String, nil] machine name on the tailnet.
|
|
238
|
+
# @param args [Array<String>] extra args passed through to +tailscale up+.
|
|
239
|
+
# @return [Result]
|
|
240
|
+
def tailscale_up(authkey: nil, hostname: nil, args: [])
|
|
241
|
+
action = ["setup"]
|
|
242
|
+
action.push("--hostname", hostname) if hostname
|
|
243
|
+
action.concat(args)
|
|
244
|
+
agent("tailscale", action, env: authkey ? { "TS_AUTHKEY" => authkey } : {})
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
private
|
|
248
|
+
|
|
249
|
+
# Run a fire-and-forget lifecycle CLI command, raising on failure.
|
|
250
|
+
def lifecycle(args, label)
|
|
251
|
+
Process.run!(args, label: label)
|
|
252
|
+
nil
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Run an in-guest agent CLI family (+ssh+, +tailscale+), raising on failure.
|
|
256
|
+
def agent(family, action, env: {})
|
|
257
|
+
res = Process.run([family, @id, *action], env: env)
|
|
258
|
+
result = Result.new(
|
|
259
|
+
stdout: res.stdout, stderr: res.stderr, exit_code: res.exit_code,
|
|
260
|
+
command: "#{family} #{action.join(' ')}"
|
|
261
|
+
)
|
|
262
|
+
result.throw_if_failed!
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bsdkrun
|
|
4
|
+
# Host-level toolchain / image operations.
|
|
5
|
+
module System
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# Sanity-check the toolchain: verify libkrun links and a context can be
|
|
9
|
+
# created/configured. Does not boot.
|
|
10
|
+
# @return [Boolean] true on success.
|
|
11
|
+
def probe
|
|
12
|
+
Process.run(["probe"]).exit_code.zero?
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Download + prepare a BSD image ahead of time.
|
|
16
|
+
#
|
|
17
|
+
# @param os [String] "freebsd" or "netbsd".
|
|
18
|
+
# @param version [String, nil]
|
|
19
|
+
# @param force [Boolean] re-download even if cached.
|
|
20
|
+
# @return [String] the command output.
|
|
21
|
+
def fetch_image(os, version: nil, force: false)
|
|
22
|
+
args = ["fetch", "--os", os.to_s]
|
|
23
|
+
args.push("--version", version.to_s) if version
|
|
24
|
+
args << "--force" if force
|
|
25
|
+
Process.run!(args, label: "bsdkrun fetch").stdout
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# List the builds available to fetch for a BSD, one version per line.
|
|
29
|
+
#
|
|
30
|
+
# @param os [String] "freebsd" or "netbsd".
|
|
31
|
+
# @return [Array<String>] version strings (non-empty output lines).
|
|
32
|
+
def versions(os)
|
|
33
|
+
res = Process.run!(["versions", "--os", os.to_s], label: "bsdkrun versions")
|
|
34
|
+
res.stdout.split("\n").map(&:strip).reject(&:empty?)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Grow a raw disk image (the guest expands its root FS on next boot).
|
|
38
|
+
#
|
|
39
|
+
# @param disk [String]
|
|
40
|
+
# @param size [String]
|
|
41
|
+
# @return [void]
|
|
42
|
+
def grow_disk(disk, size)
|
|
43
|
+
Process.run!(["grow", "--disk", disk, "--size", size], label: "bsdkrun grow")
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bsdkrun
|
|
4
|
+
# A machine as reported by +bsdkrun ps --json+.
|
|
5
|
+
#
|
|
6
|
+
# @!attribute [r] id
|
|
7
|
+
# @return [String]
|
|
8
|
+
# @!attribute [r] name
|
|
9
|
+
# @return [String, nil] DNS name on a network, or nil if unnamed.
|
|
10
|
+
# @!attribute [r] status
|
|
11
|
+
# @return [String] "running" or "exited" (derived from +running+).
|
|
12
|
+
SandboxInfo = Data.define(
|
|
13
|
+
:id, :name, :image, :kind, :command, :status, :running, :exit_code,
|
|
14
|
+
:pid, :detached, :cpus, :mem, :volume, :state_dir, :network, :net_ip,
|
|
15
|
+
:created_at, :finished_at
|
|
16
|
+
) do
|
|
17
|
+
# Map a +ps --json+ row (String keys) to a typed instance.
|
|
18
|
+
# @param row [Hash]
|
|
19
|
+
# @return [SandboxInfo]
|
|
20
|
+
def self.from_row(row)
|
|
21
|
+
running = !!row["running"]
|
|
22
|
+
new(
|
|
23
|
+
id: row["id"].to_s,
|
|
24
|
+
name: row["name"],
|
|
25
|
+
image: row["image"].to_s,
|
|
26
|
+
kind: row["kind"].to_s,
|
|
27
|
+
command: (row["command"] || "").to_s,
|
|
28
|
+
status: running ? "running" : "exited",
|
|
29
|
+
running: running,
|
|
30
|
+
exit_code: to_i_or_nil(row["exit_code"]),
|
|
31
|
+
pid: to_i_or_nil(row["pid"]),
|
|
32
|
+
detached: !!row["detached"],
|
|
33
|
+
cpus: row["cpus"].to_i,
|
|
34
|
+
mem: row["mem"].to_i,
|
|
35
|
+
volume: row["volume"],
|
|
36
|
+
state_dir: row["state_dir"].to_s,
|
|
37
|
+
network: row["network"],
|
|
38
|
+
net_ip: row["net_ip"],
|
|
39
|
+
created_at: row["created_at"].to_i,
|
|
40
|
+
finished_at: to_i_or_nil(row["finished_at"])
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.to_i_or_nil(value)
|
|
45
|
+
value.nil? ? nil : value.to_i
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# An image as reported by +bsdkrun images --json+.
|
|
50
|
+
ImageInfo = Data.define(:id, :reference, :digest, :size, :rootfs, :created_at) do
|
|
51
|
+
# @param row [Hash]
|
|
52
|
+
# @return [ImageInfo]
|
|
53
|
+
def self.from_row(row)
|
|
54
|
+
new(
|
|
55
|
+
id: row["id"].to_s,
|
|
56
|
+
reference: row["reference"].to_s,
|
|
57
|
+
digest: row["digest"].to_s,
|
|
58
|
+
size: row["size"].to_i,
|
|
59
|
+
rootfs: row["rootfs"].to_s,
|
|
60
|
+
created_at: row["created_at"].to_i
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A volume as reported by +bsdkrun volume ls --json+.
|
|
66
|
+
VolumeInfo = Data.define(:name, :guest, :base, :path, :size, :created_at, :tracked) do
|
|
67
|
+
# @param row [Hash]
|
|
68
|
+
# @return [VolumeInfo]
|
|
69
|
+
def self.from_row(row)
|
|
70
|
+
created = row["created_at"]
|
|
71
|
+
new(
|
|
72
|
+
name: row["name"].to_s,
|
|
73
|
+
guest: row["guest"],
|
|
74
|
+
base: row["base"],
|
|
75
|
+
path: row["path"].to_s,
|
|
76
|
+
size: row["size"].to_s,
|
|
77
|
+
created_at: created.nil? ? nil : created.to_i,
|
|
78
|
+
tracked: !!row["tracked"]
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# A global network as reported by +bsdkrun network ls --json+.
|
|
84
|
+
NetworkInfo = Data.define(:name, :subnet, :gateway, :members, :running, :up, :created_at) do
|
|
85
|
+
# @param row [Hash]
|
|
86
|
+
# @return [NetworkInfo]
|
|
87
|
+
def self.from_row(row)
|
|
88
|
+
created = row["created_at"]
|
|
89
|
+
new(
|
|
90
|
+
name: row["name"].to_s,
|
|
91
|
+
subnet: row["subnet"].to_s,
|
|
92
|
+
gateway: row["gateway"].to_s,
|
|
93
|
+
members: (row["members"] || 0).to_i,
|
|
94
|
+
running: (row["running"] || 0).to_i,
|
|
95
|
+
up: !!row["up"],
|
|
96
|
+
created_at: created.nil? ? nil : created.to_i
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The captured result of running a command in a guest via {Sandbox#exec}.
|
|
102
|
+
#
|
|
103
|
+
# @!attribute [r] stdout
|
|
104
|
+
# @return [String]
|
|
105
|
+
# @!attribute [r] stderr
|
|
106
|
+
# @return [String]
|
|
107
|
+
# @!attribute [r] exit_code
|
|
108
|
+
# @return [Integer]
|
|
109
|
+
# @!attribute [r] command
|
|
110
|
+
# @return [String] a human label for the command.
|
|
111
|
+
Result = Data.define(:stdout, :stderr, :exit_code, :command) do
|
|
112
|
+
# @return [Boolean] whether the command succeeded (exit 0).
|
|
113
|
+
def ok?
|
|
114
|
+
exit_code.zero?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @return [String] stdout with trailing newlines trimmed — the common case.
|
|
118
|
+
def text
|
|
119
|
+
stdout.sub(/\n+\z/, "")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @return [Object] stdout parsed as JSON.
|
|
123
|
+
def json
|
|
124
|
+
require "json"
|
|
125
|
+
JSON.parse(stdout)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @return [Array<String>] non-empty stdout lines.
|
|
129
|
+
def lines
|
|
130
|
+
stdout.split("\n").reject(&:empty?)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# @raise [CommandFailed] if the command exited non-zero.
|
|
134
|
+
# @return [self]
|
|
135
|
+
def throw_if_failed!
|
|
136
|
+
unless ok?
|
|
137
|
+
raise CommandFailed.new(
|
|
138
|
+
exit_code: exit_code, stdout: stdout, stderr: stderr, command: command
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
self
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Bsdkrun
|
|
6
|
+
# Host-level volume operations.
|
|
7
|
+
module Volumes
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# List persistent volumes.
|
|
11
|
+
# @return [Array<VolumeInfo>]
|
|
12
|
+
def list
|
|
13
|
+
res = Process.run!(["volume", "ls", "--json"], label: "bsdkrun volume ls")
|
|
14
|
+
rows = JSON.parse(res.stdout.empty? ? "[]" : res.stdout)
|
|
15
|
+
rows.map { |row| VolumeInfo.from_row(row) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Remove one or more volumes (and their data).
|
|
19
|
+
#
|
|
20
|
+
# @param names [String, Array<String>]
|
|
21
|
+
# @param force [Boolean]
|
|
22
|
+
# @return [void]
|
|
23
|
+
def remove(names, force: false)
|
|
24
|
+
args = ["volume", "rm"]
|
|
25
|
+
args << "--force" if force
|
|
26
|
+
args.concat(Array(names))
|
|
27
|
+
Process.run!(args, label: "bsdkrun volume rm")
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/bsdkrun.rb
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "bsdkrun/version"
|
|
4
|
+
require_relative "bsdkrun/errors"
|
|
5
|
+
require_relative "bsdkrun/binary"
|
|
6
|
+
require_relative "bsdkrun/process"
|
|
7
|
+
require_relative "bsdkrun/args"
|
|
8
|
+
require_relative "bsdkrun/types"
|
|
9
|
+
require_relative "bsdkrun/sandbox"
|
|
10
|
+
require_relative "bsdkrun/images"
|
|
11
|
+
require_relative "bsdkrun/volumes"
|
|
12
|
+
require_relative "bsdkrun/networks"
|
|
13
|
+
require_relative "bsdkrun/system"
|
|
14
|
+
|
|
15
|
+
# bsdkrun — a Ruby SDK for {https://github.com/tsirysndr/bsdkrun bsdkrun}, a
|
|
16
|
+
# Firecracker-style microVM launcher for BSD and Linux guests. A thin wrapper
|
|
17
|
+
# around the +bsdkrun+ CLI: it builds argv, shells out, and parses JSON output.
|
|
18
|
+
#
|
|
19
|
+
# @example
|
|
20
|
+
# require "bsdkrun"
|
|
21
|
+
#
|
|
22
|
+
# box = Bsdkrun::Sandbox.create(os: "linux", image: "alpine")
|
|
23
|
+
# puts box.exec(["uname", "-a"]).text
|
|
24
|
+
# box.stop
|
|
25
|
+
module Bsdkrun
|
|
26
|
+
class << self
|
|
27
|
+
# Force the SDK to use a specific +bsdkrun+ binary, bypassing discovery.
|
|
28
|
+
# @param path [String, nil]
|
|
29
|
+
# @return [void]
|
|
30
|
+
def binary_path=(path)
|
|
31
|
+
Binary.override = path
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [String] the resolved +bsdkrun+ binary path.
|
|
35
|
+
# @raise [BinaryNotFound]
|
|
36
|
+
def binary_path
|
|
37
|
+
Binary.resolve
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Reset cached binary discovery state (mainly for tests).
|
|
41
|
+
# @return [void]
|
|
42
|
+
def reset_binary!
|
|
43
|
+
Binary.reset!
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Convenience alias for {Sandbox.create}.
|
|
47
|
+
# @return [Sandbox]
|
|
48
|
+
def create(opts = {}, **kwargs)
|
|
49
|
+
Sandbox.create(opts, **kwargs)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Convenience alias for {Sandbox.get}.
|
|
53
|
+
# @return [Sandbox]
|
|
54
|
+
def get(id)
|
|
55
|
+
Sandbox.get(id)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Convenience alias for {Sandbox.list}.
|
|
59
|
+
# @return [Array<SandboxInfo>]
|
|
60
|
+
def list(all: false)
|
|
61
|
+
Sandbox.list(all: all)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The {Images} namespace.
|
|
65
|
+
# @return [Module]
|
|
66
|
+
def images
|
|
67
|
+
Images
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The {Volumes} namespace.
|
|
71
|
+
# @return [Module]
|
|
72
|
+
def volumes
|
|
73
|
+
Volumes
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The {Networks} namespace.
|
|
77
|
+
# @return [Module]
|
|
78
|
+
def networks
|
|
79
|
+
Networks
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The {System} namespace.
|
|
83
|
+
# @return [Module]
|
|
84
|
+
def system
|
|
85
|
+
System
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: bsdkrun
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Tsiry Sandratraina
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: minitest
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '5.0'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '5.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rake
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '13.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '13.0'
|
|
40
|
+
description: |
|
|
41
|
+
A thin, dependency-free Ruby wrapper around the `bsdkrun` CLI. Boot and drive
|
|
42
|
+
BSD/Linux microVMs programmatically: create sandboxes, exec commands, manage
|
|
43
|
+
lifecycle, and wire up global networks, volumes and images.
|
|
44
|
+
email:
|
|
45
|
+
- tsiry.sndr@gmail.com
|
|
46
|
+
executables: []
|
|
47
|
+
extensions: []
|
|
48
|
+
extra_rdoc_files: []
|
|
49
|
+
files:
|
|
50
|
+
- README.md
|
|
51
|
+
- lib/bsdkrun.rb
|
|
52
|
+
- lib/bsdkrun/args.rb
|
|
53
|
+
- lib/bsdkrun/binary.rb
|
|
54
|
+
- lib/bsdkrun/errors.rb
|
|
55
|
+
- lib/bsdkrun/images.rb
|
|
56
|
+
- lib/bsdkrun/networks.rb
|
|
57
|
+
- lib/bsdkrun/process.rb
|
|
58
|
+
- lib/bsdkrun/sandbox.rb
|
|
59
|
+
- lib/bsdkrun/system.rb
|
|
60
|
+
- lib/bsdkrun/types.rb
|
|
61
|
+
- lib/bsdkrun/version.rb
|
|
62
|
+
- lib/bsdkrun/volumes.rb
|
|
63
|
+
homepage: https://github.com/tsirysndr/bsdkrun
|
|
64
|
+
licenses:
|
|
65
|
+
- MIT
|
|
66
|
+
metadata:
|
|
67
|
+
homepage_uri: https://github.com/tsirysndr/bsdkrun
|
|
68
|
+
source_code_uri: https://github.com/tsirysndr/bsdkrun/tree/main/sdk/ruby
|
|
69
|
+
rubygems_mfa_required: 'true'
|
|
70
|
+
rdoc_options: []
|
|
71
|
+
require_paths:
|
|
72
|
+
- lib
|
|
73
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
74
|
+
requirements:
|
|
75
|
+
- - ">="
|
|
76
|
+
- !ruby/object:Gem::Version
|
|
77
|
+
version: '3.2'
|
|
78
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - ">="
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '0'
|
|
83
|
+
requirements: []
|
|
84
|
+
rubygems_version: 4.0.10
|
|
85
|
+
specification_version: 4
|
|
86
|
+
summary: Ruby SDK for bsdkrun — a Firecracker-style microVM launcher for BSD and Linux
|
|
87
|
+
guests.
|
|
88
|
+
test_files: []
|