bsdkrun 0.3.2 → 0.5.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 +4 -4
- data/README.md +124 -21
- data/lib/bsdkrun/args.rb +13 -0
- data/lib/bsdkrun/cache.rb +112 -0
- data/lib/bsdkrun/client.rb +271 -1
- data/lib/bsdkrun/errors.rb +13 -0
- data/lib/bsdkrun/filesystem.rb +105 -0
- data/lib/bsdkrun/process.rb +10 -2
- data/lib/bsdkrun/sandbox.rb +15 -3
- data/lib/bsdkrun/types.rb +183 -3
- data/lib/bsdkrun/version.rb +1 -1
- data/lib/bsdkrun.rb +5 -3
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d1b2ffdacfec58cee3714b8ca96ecc133604655d423e264444d8f41ea88393e5
|
|
4
|
+
data.tar.gz: 848ec7c03c5489a41daf404135c3bbfdcfb126449ecb712ddf920ea7377bc822
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a86228909ae198caab0e630bac039179fc115317118f321a1aa193683ee6fb10ccfdc56ae1d05d42cad3ebad712897b5979c0b3b75cd0fe9b64bef0406d1d5ae
|
|
7
|
+
data.tar.gz: aef36636aef92124b1c78695648c929bfee4f60e134b9ef0a2d5588693827994e8023f62c91f39cd2d1a19c74e7e1a6a3d99a99c2aca9df51ba53bf03022368e
|
data/README.md
CHANGED
|
@@ -9,14 +9,14 @@ dependencies** — just the Ruby standard library (`open3`, `json`, `pathname`).
|
|
|
9
9
|
```ruby
|
|
10
10
|
require "bsdkrun"
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
sbx = Bsdkrun::Sandbox.create(os: "linux", image: "alpine")
|
|
13
13
|
|
|
14
14
|
# exec argv directly, with env / stdin / a PTY / a working dir:
|
|
15
|
-
puts
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
puts sbx.exec(["uname", "-a"]).text
|
|
16
|
+
sbx.exec(["apk", "add", "curl"], throw_on_error: true)
|
|
17
|
+
sbx.run_command("curl", ["-fsSL", "https://example.com"])
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
sbx.stop
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
## Install
|
|
@@ -77,15 +77,34 @@ Bsdkrun::Sandbox.create(os: "kernel", kernel: "netbsd", format: "elf", disk: "ro
|
|
|
77
77
|
|
|
78
78
|
Every `create` runs the machine **detached** and returns a `Sandbox` handle.
|
|
79
79
|
|
|
80
|
+
### Environment variables
|
|
81
|
+
|
|
82
|
+
`env:` sets the guest environment for the machine's entrypoint. It is merged
|
|
83
|
+
over the image's own config, so a key the image already defines is replaced
|
|
84
|
+
rather than duplicated.
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
sbx = Bsdkrun::Sandbox.create(
|
|
88
|
+
os: "linux",
|
|
89
|
+
image: "node:22",
|
|
90
|
+
env: { "NODE_ENV" => "production", "PORT" => "3000" },
|
|
91
|
+
command: ["node", "server.js"]
|
|
92
|
+
)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Linux guests only — BSD guests boot their own init, so there is no generated
|
|
96
|
+
init to export into; set those from `exec` after boot. For a single command
|
|
97
|
+
rather than the whole machine, `exec` takes its own `env:`.
|
|
98
|
+
|
|
80
99
|
## Running commands
|
|
81
100
|
|
|
82
101
|
`exec` is the primary programmatic entrypoint. No shell parsing — pass an argv
|
|
83
102
|
array (or a program name plus `args:`).
|
|
84
103
|
|
|
85
104
|
```ruby
|
|
86
|
-
|
|
105
|
+
sbx.exec(["ls", "-la", "/etc"])
|
|
87
106
|
|
|
88
|
-
|
|
107
|
+
sbx.exec("ruby",
|
|
89
108
|
args: ["-e", "puts ENV['X']"],
|
|
90
109
|
env: { "X" => "hi" },
|
|
91
110
|
cwd: "/app",
|
|
@@ -96,7 +115,7 @@ box.exec("ruby",
|
|
|
96
115
|
throw_on_error: true) # raise on non-zero exit (default: false)
|
|
97
116
|
|
|
98
117
|
# Vercel-Sandbox-style alias:
|
|
99
|
-
result =
|
|
118
|
+
result = sbx.run_command("uname", ["-a"])
|
|
100
119
|
result.stdout # raw stdout
|
|
101
120
|
result.text # stdout, trailing newlines trimmed
|
|
102
121
|
result.exit_code
|
|
@@ -111,21 +130,74 @@ The callbacks run as chunks arrive, and the same bytes remain buffered in the
|
|
|
111
130
|
returned result. They do not require `tty`; a PTY changes command semantics and
|
|
112
131
|
may merge stderr into stdout.
|
|
113
132
|
|
|
133
|
+
## Caching
|
|
134
|
+
|
|
135
|
+
`sbx.cache` saves a guest directory under a key and restores it later, so a
|
|
136
|
+
rebuild can pick up where the last one left off. **A miss is not an error** —
|
|
137
|
+
check `restored` rather than rescuing.
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
key = "deps-#{lock_hash}"
|
|
141
|
+
hit = sbx.cache.restore(key: key, restore_keys: ["deps-"])
|
|
142
|
+
unless hit.restored
|
|
143
|
+
sbx.exec(["npm", "ci"])
|
|
144
|
+
sbx.cache.save("/app/node_modules", key: key, compression: "zstd")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
Bsdkrun::Caches.ls # every stored entry, newest first
|
|
148
|
+
Bsdkrun::Caches.rm([key]) # or Bsdkrun::Caches.rm(all: true)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`restore_keys` are prefixes tried in order when the exact key misses; within a
|
|
152
|
+
prefix the newest matching entry wins, and `hit.key` says which one was used.
|
|
153
|
+
Formats are `gzip` (default), `zstd`, `estargz` and `none`.
|
|
154
|
+
|
|
155
|
+
Where entries live is host configuration, not an SDK concern: the default is
|
|
156
|
+
this host's disk, and `BSDKRUN_CACHE_BACKEND=s3` + `BSDKRUN_CACHE_S3_*` (or
|
|
157
|
+
`~/.config/bsdkrun/cache.toml`) points them at a bucket instead.
|
|
158
|
+
|
|
159
|
+
## Files
|
|
160
|
+
|
|
161
|
+
`sbx.fs` reads and writes files in the guest. Parent directories are created
|
|
162
|
+
for you, and everything is byte-exact — `read_file` returns a binary string.
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
sbx.fs.write_file("/app/main.py", "print('hi')")
|
|
166
|
+
sbx.fs.write_file("/app/logo.png", png_bytes)
|
|
167
|
+
|
|
168
|
+
text = sbx.fs.read_text("/app/out.json")
|
|
169
|
+
bytes = sbx.fs.read_file("/app/logo.png")
|
|
170
|
+
|
|
171
|
+
sbx.fs.upload("./src", "/app/src") # file or directory
|
|
172
|
+
sbx.fs.download("/app/dist", "./dist", recursive: true)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
`upload` looks at the local path to decide whether to recurse; `download` cannot
|
|
176
|
+
(the path is in the guest), so say so for a directory. A directory's *contents*
|
|
177
|
+
land in the destination: uploading `./src` to `/app/src` leaves the guest's
|
|
178
|
+
`/app/src` holding what `./src` holds.
|
|
179
|
+
|
|
180
|
+
Failures raise `Bsdkrun::FileTransferFailed`, which carries the offending `path`.
|
|
181
|
+
|
|
182
|
+
> Transfers ride the same in-guest agent as `exec`, so the sandbox must be
|
|
183
|
+
> running. A directory copy also needs `tar` in the guest; single files need
|
|
184
|
+
> only the shell every bootable image already has.
|
|
185
|
+
|
|
114
186
|
## Lifecycle & inventory
|
|
115
187
|
|
|
116
188
|
```ruby
|
|
117
|
-
|
|
118
|
-
same = Bsdkrun::Sandbox.get(
|
|
189
|
+
sbx = Bsdkrun::Sandbox.create(os: "linux", image: "alpine", command: ["sleep", "300"])
|
|
190
|
+
same = Bsdkrun::Sandbox.get(sbx.id) # reconnect (prefix ok)
|
|
119
191
|
all = Bsdkrun::Sandbox.list(all: true) # Array<SandboxInfo>
|
|
120
192
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
193
|
+
sbx.status # SandboxInfo | nil
|
|
194
|
+
sbx.running? # true / false
|
|
195
|
+
sbx.logs # console log (String)
|
|
196
|
+
sbx.shell # interactive shell (inherits the terminal)
|
|
197
|
+
sbx.stop # BSD guests clean-poweroff; Linux SIGTERM
|
|
198
|
+
sbx.start # restart in place — resumes its own disk/rootfs (data persists)
|
|
199
|
+
sbx.update(cpus: 4, mem: 2048) # applies on next start
|
|
200
|
+
sbx.remove(force: true)
|
|
129
201
|
```
|
|
130
202
|
|
|
131
203
|
Host-level namespaces:
|
|
@@ -147,11 +219,11 @@ Bsdkrun::System.versions("netbsd") # Array<String>
|
|
|
147
219
|
Bsdkrun::Sandbox.create(os: "linux", image: "alpine", net: { ports: ["2222:22"] })
|
|
148
220
|
|
|
149
221
|
# agent-managed key-based SSH
|
|
150
|
-
|
|
151
|
-
|
|
222
|
+
sbx.ssh_setup # install local ~/.ssh/*.pub keys
|
|
223
|
+
sbx.ssh_setup(user: "tsiry", key: "~/.ssh/work.pub")
|
|
152
224
|
|
|
153
225
|
# put a guest on your tailnet
|
|
154
|
-
|
|
226
|
+
sbx.tailscale_up(authkey: "tskey-auth-...", hostname: "web")
|
|
155
227
|
```
|
|
156
228
|
|
|
157
229
|
### Global networks — reach machines by name
|
|
@@ -228,6 +300,37 @@ the new machine's id. `run_solo5` boots a MirageOS unikernel under the
|
|
|
228
300
|
`stop`/`start`/`remove`/`update`/`commit` return a
|
|
229
301
|
`CommandResult` (`exit_code`, `stdout`, `stderr`).
|
|
230
302
|
|
|
303
|
+
### Snapshots
|
|
304
|
+
|
|
305
|
+
A snapshot is a **copy-on-write clone of a machine's disk state** — instant to
|
|
306
|
+
take, free until the two sides diverge. `branch` boots a new machine from one
|
|
307
|
+
(or from a machine, which is snapshotted first); `restore`/`rollback` put one
|
|
308
|
+
back, leaving the machine stopped. A BSD guest is powered off to snapshot it:
|
|
309
|
+
a mounted UFS cannot be cloned consistently.
|
|
310
|
+
|
|
311
|
+
```ruby
|
|
312
|
+
snap = client.snapshot(machine_id, name: "before-upgrade")
|
|
313
|
+
client.snapshots(machine: machine_id) # newest first
|
|
314
|
+
branch_id = client.branch(snap.name, name: "web-test")
|
|
315
|
+
client.restore(machine_id, snap.name) # or client.rollback(machine_id)
|
|
316
|
+
client.remove_snapshots(snap.name)
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
### Docker
|
|
320
|
+
|
|
321
|
+
bsdkrun runs one `docker:dind` microVM and serves its API on a host unix
|
|
322
|
+
socket, so the host's own `docker` CLI drives the same engine these calls do.
|
|
323
|
+
Starting is idempotent — the VM has a fixed name, so it resumes rather than
|
|
324
|
+
creating a second.
|
|
325
|
+
|
|
326
|
+
```ruby
|
|
327
|
+
status = client.docker_start(cpus: 4, mem: 4096) # or just docker_status
|
|
328
|
+
puts status.socket
|
|
329
|
+
client.docker_containers.each { |c| puts "#{c.name} #{c.state} #{c.ports}" }
|
|
330
|
+
client.docker_container("restart", "web")
|
|
331
|
+
puts client.docker_logs("web", tail: 50)
|
|
332
|
+
```
|
|
333
|
+
|
|
231
334
|
For a live terminal instead of a one-shot `exec`, use `shell`:
|
|
232
335
|
|
|
233
336
|
```ruby
|
data/lib/bsdkrun/args.rb
CHANGED
|
@@ -28,6 +28,17 @@ module Bsdkrun
|
|
|
28
28
|
# +--name+ flag if a name is set.
|
|
29
29
|
# @param o [Hash]
|
|
30
30
|
# @return [Array<String>]
|
|
31
|
+
# +-e K=V+ per entry, sorted by key.
|
|
32
|
+
#
|
|
33
|
+
# A hash has no argv order of its own, so sorting is what makes the command
|
|
34
|
+
# line — and the tests that assert on it — deterministic. The guest sees the
|
|
35
|
+
# same environment either way.
|
|
36
|
+
def env_args(env)
|
|
37
|
+
return [] if env.nil? || env.empty?
|
|
38
|
+
|
|
39
|
+
env.to_h.sort_by { |k, _| k.to_s }.flat_map { |k, v| ["-e", "#{k}=#{v}"] }
|
|
40
|
+
end
|
|
41
|
+
|
|
31
42
|
def name_args(o)
|
|
32
43
|
o[:name] ? ["--name", o[:name].to_s] : []
|
|
33
44
|
end
|
|
@@ -81,7 +92,9 @@ module Bsdkrun
|
|
|
81
92
|
a.push("--initramfs") if opts[:initramfs]
|
|
82
93
|
a.push("-v", opts[:volume]) if opts[:volume]
|
|
83
94
|
Array(opts[:mounts]).each { |m| a.push("--mount", m) }
|
|
95
|
+
Array(opts[:attach_disk]).each { |d| a.push("--attach-disk", d) }
|
|
84
96
|
a.push("--entrypoint", opts[:entrypoint]) if opts[:entrypoint]
|
|
97
|
+
a.concat(env_args(opts[:env]))
|
|
85
98
|
a.push("--console", opts[:console]) if opts[:console]
|
|
86
99
|
a.concat(net_args(opts[:net])).concat(name_args(opts)).concat(vm_args(opts))
|
|
87
100
|
cmd = opts[:command]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Bsdkrun
|
|
6
|
+
# A stored cache entry, as +cache ls+ reports it.
|
|
7
|
+
CacheEntry = Struct.new(:key, :path, :compression, :size, :created, :digest, keyword_init: true) do
|
|
8
|
+
def self.from(row)
|
|
9
|
+
new(
|
|
10
|
+
key: row["key"].to_s,
|
|
11
|
+
path: row["path"].to_s,
|
|
12
|
+
compression: row["compression"].to_s,
|
|
13
|
+
size: row["size"].to_i,
|
|
14
|
+
created: row["created"].to_i,
|
|
15
|
+
digest: row["digest"].to_s
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# What a restore did. A miss is not an error — check +restored+.
|
|
21
|
+
RestoreResult = Struct.new(
|
|
22
|
+
:restored, :requested_key, :key, :path, :size, :compression, :created,
|
|
23
|
+
keyword_init: true
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Save and restore guest directories under a key, reached as {Sandbox#cache}.
|
|
27
|
+
#
|
|
28
|
+
# Entries are keyed, so a rebuild can pick up where the last one left off:
|
|
29
|
+
#
|
|
30
|
+
# hit = sbx.cache.restore(key: key, restore_keys: ["deps-"])
|
|
31
|
+
# unless hit.restored
|
|
32
|
+
# sbx.exec(["npm", "ci"])
|
|
33
|
+
# sbx.cache.save("/app/node_modules", key: key)
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# Where entries live — host disk or S3 — is host configuration, not an SDK
|
|
37
|
+
# concern: set +BSDKRUN_CACHE_BACKEND+ / +BSDKRUN_CACHE_S3_*+, or write
|
|
38
|
+
# +~/.config/bsdkrun/cache.toml+.
|
|
39
|
+
class Cache
|
|
40
|
+
# @param id [String] the machine's id.
|
|
41
|
+
def initialize(id)
|
|
42
|
+
@id = id
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Archive the guest directory at +path+ under +key+.
|
|
46
|
+
#
|
|
47
|
+
# @param path [String] absolute path in the guest.
|
|
48
|
+
# @param key [String] key to store under.
|
|
49
|
+
# @param compression [String] gzip (default), zstd, estargz or none.
|
|
50
|
+
# @param force [Boolean] replace an entry that already has this key.
|
|
51
|
+
# @return [CacheEntry]
|
|
52
|
+
def save(path, key:, compression: "gzip", force: false)
|
|
53
|
+
args = ["cache", "save", "#{@id}:#{path}", "--key", key, "--json"]
|
|
54
|
+
args += ["--compression", compression] unless compression == "gzip"
|
|
55
|
+
args << "--force" if force
|
|
56
|
+
CacheEntry.from(json(args, "bsdkrun cache save"))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Restore a stored tree.
|
|
60
|
+
#
|
|
61
|
+
# @param key [String]
|
|
62
|
+
# @param path [String, nil] defaults to where the entry was saved from.
|
|
63
|
+
# @param restore_keys [Array<String>] prefixes tried in order on a miss.
|
|
64
|
+
# @return [RestoreResult]
|
|
65
|
+
def restore(key:, path: nil, restore_keys: [])
|
|
66
|
+
target = path ? "#{@id}:#{path}" : @id
|
|
67
|
+
args = ["cache", "restore", target, "--key", key, "--json"]
|
|
68
|
+
args += ["--restore-keys", *restore_keys] unless restore_keys.empty?
|
|
69
|
+
row = json(args, "bsdkrun cache restore")
|
|
70
|
+
RestoreResult.new(
|
|
71
|
+
restored: !!row["restored"],
|
|
72
|
+
requested_key: row["requested_key"].to_s,
|
|
73
|
+
key: row["key"],
|
|
74
|
+
path: row["path"],
|
|
75
|
+
size: row["size"],
|
|
76
|
+
compression: row["compression"],
|
|
77
|
+
created: row["created"]
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def json(args, label)
|
|
84
|
+
out = Process.run!(args, label: label).stdout
|
|
85
|
+
JSON.parse(out.strip.empty? ? "{}" : out)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Host-level cache operations, mirroring {Bsdkrun.volumes}.
|
|
90
|
+
module Caches
|
|
91
|
+
module_function
|
|
92
|
+
|
|
93
|
+
# @return [Array<CacheEntry>] every stored entry, newest first.
|
|
94
|
+
def ls
|
|
95
|
+
out = Process.run!(["cache", "ls", "--json"], label: "bsdkrun cache ls").stdout
|
|
96
|
+
JSON.parse(out.strip.empty? ? "[]" : out).map { |row| CacheEntry.from(row) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Remove entries by key, or every one with <tt>all: true</tt>.
|
|
100
|
+
# @return [void]
|
|
101
|
+
def rm(keys = [], all: false)
|
|
102
|
+
args = ["cache", "rm"]
|
|
103
|
+
if all
|
|
104
|
+
args << "--all"
|
|
105
|
+
else
|
|
106
|
+
args += Array(keys)
|
|
107
|
+
end
|
|
108
|
+
Process.run!(args, label: "bsdkrun cache rm")
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
data/lib/bsdkrun/client.rb
CHANGED
|
@@ -35,10 +35,16 @@ module Bsdkrun
|
|
|
35
35
|
# +web/src/lib/api.ts+'s +MACHINE_FIELDS+ fragment exactly.
|
|
36
36
|
MACHINE_FIELDS = <<~GQL.freeze
|
|
37
37
|
id name image kind command status running exitCode pid detached
|
|
38
|
-
cpus mem volume stateDir createdAt finishedAt network netIp
|
|
38
|
+
cpus mem volume stateDir createdAt finishedAt network netIp origin
|
|
39
39
|
ports { bind host guest }
|
|
40
40
|
GQL
|
|
41
41
|
|
|
42
|
+
# The +Snapshot+ selection, likewise shared by every snapshot document.
|
|
43
|
+
SNAPSHOT_FIELDS = <<~GQL.freeze
|
|
44
|
+
id name machineId machineName kind image path parent description
|
|
45
|
+
cpus mem size createdAt ports { bind host guest }
|
|
46
|
+
GQL
|
|
47
|
+
|
|
42
48
|
# @return [String] the GraphQL endpoint URL (normalized).
|
|
43
49
|
attr_reader :url
|
|
44
50
|
|
|
@@ -242,6 +248,269 @@ module Bsdkrun
|
|
|
242
248
|
)
|
|
243
249
|
end
|
|
244
250
|
|
|
251
|
+
# ---- ai agents -------------------------------------------------------------
|
|
252
|
+
#
|
|
253
|
+
# A sandbox is a machine, so its terminal is the ordinary {#shell} with the
|
|
254
|
+
# argv {#ai_shell_command} returns.
|
|
255
|
+
|
|
256
|
+
AI_AGENT_FIELDS = "id label flavor description installed running"
|
|
257
|
+
AI_SESSION_FIELDS = "id name agent running workspace createdAt"
|
|
258
|
+
|
|
259
|
+
# The coding agents, and whether each one's sandbox image is built.
|
|
260
|
+
# @return [Array<AiAgent>]
|
|
261
|
+
def ai_agents
|
|
262
|
+
data = request("{ aiAgents { #{AI_AGENT_FIELDS} } }")
|
|
263
|
+
(data["aiAgents"] || []).map { |a| AiAgent.from_graphql(a) }
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Agent sandboxes, newest first.
|
|
267
|
+
# @return [Array<AiSession>]
|
|
268
|
+
def ai_sessions
|
|
269
|
+
data = request("{ aiSessions { #{AI_SESSION_FIELDS} } }")
|
|
270
|
+
(data["aiSessions"] || []).map { |s| AiSession.from_graphql(s) }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Start (or reuse) a sandbox; returns its machine id.
|
|
274
|
+
#
|
|
275
|
+
# @param agent [String] +claude+, +codex+, ...
|
|
276
|
+
# @param workspace [String, nil] a directory **on the engine's host**.
|
|
277
|
+
# @param new [Boolean] boot a second sandbox against the same saved login.
|
|
278
|
+
# @return [String]
|
|
279
|
+
def ai_start(agent, cpus: nil, mem: nil, workspace: nil, new: false)
|
|
280
|
+
data = request(
|
|
281
|
+
"mutation($input:AiStartInput!){ aiStart(input:$input) }",
|
|
282
|
+
{ input: { agent: agent, cpus: cpus, mem: mem,
|
|
283
|
+
workspace: workspace, new: new } }
|
|
284
|
+
)
|
|
285
|
+
data["aiStart"].to_s
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# The argv that starts the agent's TUI — pass it to {#shell}.
|
|
289
|
+
# @return [Array<String>]
|
|
290
|
+
def ai_shell_command(agent, machine_id)
|
|
291
|
+
data = request(
|
|
292
|
+
"query($agent:String!,$machineId:String!){ " \
|
|
293
|
+
"aiShellCommand(agent:$agent, machineId:$machineId) }",
|
|
294
|
+
{ agent: agent, machineId: machine_id }
|
|
295
|
+
)
|
|
296
|
+
Array(data["aiShellCommand"])
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Stop an agent's sandboxes. Its saved login survives.
|
|
300
|
+
# @return [CommandResult]
|
|
301
|
+
def ai_stop(agent)
|
|
302
|
+
run_command_mutation(
|
|
303
|
+
"aiStop",
|
|
304
|
+
"mutation($agent:String!){ aiStop(agent:$agent){ exitCode stdout stderr } }",
|
|
305
|
+
{ agent: agent }
|
|
306
|
+
)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Remove an agent's sandboxes, and unless +keep_home+ its saved login too.
|
|
310
|
+
# @return [CommandResult]
|
|
311
|
+
def ai_remove(agent, keep_home: false)
|
|
312
|
+
run_command_mutation(
|
|
313
|
+
"aiRemove",
|
|
314
|
+
"mutation($agent:String!,$keepHome:Boolean!){ " \
|
|
315
|
+
"aiRemove(agent:$agent, keepHome:$keepHome){ exitCode stdout stderr } }",
|
|
316
|
+
{ agent: agent, keepHome: keep_home }
|
|
317
|
+
)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# ---- docker --------------------------------------------------------------
|
|
321
|
+
#
|
|
322
|
+
# bsdkrun runs one +docker:dind+ microVM and serves its API on a host unix
|
|
323
|
+
# socket, so these drive the same engine the host's +docker+ CLI does.
|
|
324
|
+
|
|
325
|
+
DOCKER_STATUS_FIELDS = <<~GQL.freeze
|
|
326
|
+
running machineId machineRunning socket socketReady apiPort version
|
|
327
|
+
containers images mounts disk diskSize
|
|
328
|
+
GQL
|
|
329
|
+
|
|
330
|
+
DOCKER_CONTAINER_FIELDS = "id name image command state status ports created"
|
|
331
|
+
|
|
332
|
+
# Is the Docker engine up, and where is its socket?
|
|
333
|
+
# @return [DockerStatus]
|
|
334
|
+
def docker_status
|
|
335
|
+
data = request("{ dockerStatus { #{DOCKER_STATUS_FIELDS} } }")
|
|
336
|
+
DockerStatus.from_graphql(data["dockerStatus"])
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Containers in the engine.
|
|
340
|
+
# @param all [Boolean] include stopped ones (default true).
|
|
341
|
+
# @return [Array<DockerContainer>]
|
|
342
|
+
def docker_containers(all: true)
|
|
343
|
+
data = request(
|
|
344
|
+
"query($all:Boolean!){ dockerContainers(all:$all){ #{DOCKER_CONTAINER_FIELDS} } }",
|
|
345
|
+
{ all: all }
|
|
346
|
+
)
|
|
347
|
+
(data["dockerContainers"] || []).map { |c| DockerContainer.from_graphql(c) }
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Start (or resume) the engine, returning its status once it answers.
|
|
351
|
+
#
|
|
352
|
+
# Idempotent: the VM has a fixed name, so this resumes the existing one
|
|
353
|
+
# rather than creating a second.
|
|
354
|
+
#
|
|
355
|
+
# @param cpus [Integer, nil]
|
|
356
|
+
# @param mem [Integer, nil]
|
|
357
|
+
# @param mounts [Array<String>] host dirs to share, +PATH+ or +HOST:GUEST+.
|
|
358
|
+
# @param no_home [Boolean] do not share +$HOME+ (shared by default).
|
|
359
|
+
# @param publish_bind [String, nil] +mirror+ (default) or a fixed address.
|
|
360
|
+
# @param disk_size [String, nil] a dedicated image store, e.g. +60G+.
|
|
361
|
+
# @return [DockerStatus]
|
|
362
|
+
def docker_start(cpus: nil, mem: nil, mounts: [], no_home: false,
|
|
363
|
+
publish_bind: nil, disk_size: nil)
|
|
364
|
+
data = request(
|
|
365
|
+
"mutation($input:DockerStartInput!){ dockerStart(input:$input){ " \
|
|
366
|
+
"#{DOCKER_STATUS_FIELDS} } }",
|
|
367
|
+
{ input: { cpus: cpus, mem: mem, mounts: Array(mounts), noHome: no_home,
|
|
368
|
+
publishBind: publish_bind, diskSize: disk_size } }
|
|
369
|
+
)
|
|
370
|
+
DockerStatus.from_graphql(data["dockerStart"])
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Stop the engine. Images and containers stay on its disk.
|
|
374
|
+
# @return [CommandResult]
|
|
375
|
+
def docker_stop
|
|
376
|
+
run_command_mutation(
|
|
377
|
+
"dockerStop",
|
|
378
|
+
"mutation{ dockerStop{ exitCode stdout stderr } }",
|
|
379
|
+
{}
|
|
380
|
+
)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# start | stop | restart | kill | pause | unpause | rm.
|
|
384
|
+
# @param action [String]
|
|
385
|
+
# @param ids [String, Array<String>]
|
|
386
|
+
# @return [CommandResult]
|
|
387
|
+
def docker_container(action, ids)
|
|
388
|
+
run_command_mutation(
|
|
389
|
+
"dockerContainer",
|
|
390
|
+
"mutation($action:String!,$ids:[String!]!){ " \
|
|
391
|
+
"dockerContainer(action:$action, ids:$ids){ exitCode stdout stderr } }",
|
|
392
|
+
{ action: action, ids: Array(ids) }
|
|
393
|
+
)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# One container's logs (stdout+stderr, most recent +tail+ lines).
|
|
397
|
+
# @param id [String]
|
|
398
|
+
# @param tail [Integer]
|
|
399
|
+
# @return [String]
|
|
400
|
+
def docker_logs(id, tail: 200)
|
|
401
|
+
data = request(
|
|
402
|
+
"query($id:String!,$tail:Int!){ dockerContainerLogs(id:$id, tail:$tail) }",
|
|
403
|
+
{ id: id, tail: tail }
|
|
404
|
+
)
|
|
405
|
+
data["dockerContainerLogs"].to_s
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# ---- snapshots ---------------------------------------------------------
|
|
409
|
+
#
|
|
410
|
+
# A snapshot is a copy-on-write clone of a machine's disk state: instant to
|
|
411
|
+
# take, free until the two sides diverge. {#branch} boots a new machine
|
|
412
|
+
# from one; {#restore}/{#rollback} put one back.
|
|
413
|
+
|
|
414
|
+
# List snapshots, newest first.
|
|
415
|
+
# @param machine [String, nil] only this machine's, when given.
|
|
416
|
+
# @return [Array<SnapshotInfo>]
|
|
417
|
+
def snapshots(machine: nil)
|
|
418
|
+
data = request(
|
|
419
|
+
"query($machine:String){ snapshots(machine:$machine){ #{SNAPSHOT_FIELDS} } }",
|
|
420
|
+
{ machine: machine }
|
|
421
|
+
)
|
|
422
|
+
(data["snapshots"] || []).map { |s| SnapshotInfo.from_graphql(s) }
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# Capture a machine's disk state.
|
|
426
|
+
#
|
|
427
|
+
# A BSD guest is powered off first — a mounted UFS cannot be cloned
|
|
428
|
+
# consistently — so the machine is left stopped; {#start} brings it back.
|
|
429
|
+
#
|
|
430
|
+
# @param id [String]
|
|
431
|
+
# @param name [String, nil] defaults to +<machine>-<n>+.
|
|
432
|
+
# @param description [String]
|
|
433
|
+
# @return [SnapshotInfo]
|
|
434
|
+
def snapshot(id, name: nil, description: "")
|
|
435
|
+
data = request(
|
|
436
|
+
"mutation($id:String!,$name:String,$description:String!){ " \
|
|
437
|
+
"snapshotMachine(id:$id, name:$name, description:$description){ #{SNAPSHOT_FIELDS} } }",
|
|
438
|
+
{ id: id, name: name, description: description }
|
|
439
|
+
)
|
|
440
|
+
SnapshotInfo.from_graphql(data["snapshotMachine"])
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Delete snapshots and their data. Machines branched from them are
|
|
444
|
+
# unaffected.
|
|
445
|
+
# @param names [String, Array<String>]
|
|
446
|
+
# @return [CommandResult]
|
|
447
|
+
def remove_snapshots(names)
|
|
448
|
+
run_command_mutation(
|
|
449
|
+
"removeSnapshots",
|
|
450
|
+
"mutation($names:[String!]!){ removeSnapshots(names:$names){ exitCode stdout stderr } }",
|
|
451
|
+
{ names: Array(names) }
|
|
452
|
+
)
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# Put a machine's disk state back to one of its snapshots.
|
|
456
|
+
#
|
|
457
|
+
# +force+ stops the machine first (it holds the very files being
|
|
458
|
+
# replaced); +backup+ snapshots the state being overwritten, which is a
|
|
459
|
+
# CoW clone and therefore free. The machine is left stopped.
|
|
460
|
+
#
|
|
461
|
+
# @param id [String]
|
|
462
|
+
# @param snapshot [String]
|
|
463
|
+
# @param force [Boolean]
|
|
464
|
+
# @param backup [Boolean]
|
|
465
|
+
# @return [CommandResult]
|
|
466
|
+
def restore(id, snapshot, force: true, backup: true)
|
|
467
|
+
run_command_mutation(
|
|
468
|
+
"restoreMachine",
|
|
469
|
+
"mutation($id:String!,$snapshot:String!,$force:Boolean!,$backup:Boolean!){ " \
|
|
470
|
+
"restoreMachine(id:$id, snapshot:$snapshot, force:$force, backup:$backup){ " \
|
|
471
|
+
"exitCode stdout stderr } }",
|
|
472
|
+
{ id: id, snapshot: snapshot, force: force, backup: backup }
|
|
473
|
+
)
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# Restore a machine to its most recent snapshot.
|
|
477
|
+
# @param id [String]
|
|
478
|
+
# @param force [Boolean]
|
|
479
|
+
# @param backup [Boolean]
|
|
480
|
+
# @return [CommandResult]
|
|
481
|
+
def rollback(id, force: true, backup: true)
|
|
482
|
+
run_command_mutation(
|
|
483
|
+
"rollbackMachine",
|
|
484
|
+
"mutation($id:String!,$force:Boolean!,$backup:Boolean!){ " \
|
|
485
|
+
"rollbackMachine(id:$id, force:$force, backup:$backup){ exitCode stdout stderr } }",
|
|
486
|
+
{ id: id, force: force, backup: backup }
|
|
487
|
+
)
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# Boot a NEW machine from a snapshot — or from a machine, which is
|
|
491
|
+
# snapshotted first — and return the new machine's id.
|
|
492
|
+
#
|
|
493
|
+
# The state is cloned, never booted in place, so the source is untouched
|
|
494
|
+
# and one snapshot can be branched any number of times. With no +ports+,
|
|
495
|
+
# the snapshot's own forwards are inherited, with any host port that is
|
|
496
|
+
# already taken swapped for a free one.
|
|
497
|
+
#
|
|
498
|
+
# @param snapshot [String] snapshot name/id, or a machine id.
|
|
499
|
+
# @param name [String, nil]
|
|
500
|
+
# @param cpus [Integer, nil]
|
|
501
|
+
# @param mem [Integer, nil]
|
|
502
|
+
# @param ports [Array<String>]
|
|
503
|
+
# @param no_ports [Boolean]
|
|
504
|
+
# @return [String] the new machine's id.
|
|
505
|
+
def branch(snapshot, name: nil, cpus: nil, mem: nil, ports: [], no_ports: false)
|
|
506
|
+
data = request(
|
|
507
|
+
"mutation($input:BranchInput!){ branchSnapshot(input:$input) }",
|
|
508
|
+
{ input: { snapshot: snapshot, name: name, cpus: cpus, mem: mem,
|
|
509
|
+
ports: Array(ports), noPorts: no_ports } }
|
|
510
|
+
)
|
|
511
|
+
data["branchSnapshot"].to_s
|
|
512
|
+
end
|
|
513
|
+
|
|
245
514
|
# One-shot console log fetch. Use {#follow_logs} to stream instead.
|
|
246
515
|
# @param id [String]
|
|
247
516
|
# @param boot [Boolean] bsdkrun's own boot log instead of the guest console.
|
|
@@ -293,6 +562,7 @@ module Bsdkrun
|
|
|
293
562
|
net: net_input(o[:net]),
|
|
294
563
|
volume: o[:volume],
|
|
295
564
|
mounts: o[:mounts] || [],
|
|
565
|
+
attachDisk: o[:attach_disk] || [],
|
|
296
566
|
env: o[:env] || [],
|
|
297
567
|
entrypoint: o[:entrypoint],
|
|
298
568
|
initramfs: o[:initramfs] || false,
|
data/lib/bsdkrun/errors.rb
CHANGED
|
@@ -50,6 +50,19 @@ module Bsdkrun
|
|
|
50
50
|
end
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
# A guest filesystem operation was refused (see {FileSystem}).
|
|
54
|
+
class FileTransferFailed < Error
|
|
55
|
+
# @return [String] the path that could not be transferred.
|
|
56
|
+
attr_reader :path
|
|
57
|
+
|
|
58
|
+
# @param message [String]
|
|
59
|
+
# @param path [String]
|
|
60
|
+
def initialize(message, path)
|
|
61
|
+
@path = path
|
|
62
|
+
super(message)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
53
66
|
# A GraphQL request to a remote +bsdkrund+ daemon failed — a transport
|
|
54
67
|
# failure, a non-JSON response, or a +body["errors"]+ entry that was not an
|
|
55
68
|
# auth failure. Mirrors +web/src/lib/graphql.ts+'s +GraphQLError+.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bsdkrun
|
|
4
|
+
# Files in a running sandbox, reached as {Sandbox#fs}.
|
|
5
|
+
#
|
|
6
|
+
# Every call goes through the guest's exec agent, so the sandbox has to be
|
|
7
|
+
# running — there is no offline write.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# sbx.fs.write_file("/app/main.py", "print('hi')")
|
|
11
|
+
# sbx.fs.read_text("/app/out.json")
|
|
12
|
+
# sbx.fs.upload("./src", "/app/src")
|
|
13
|
+
# sbx.fs.download("/app/dist", "./dist", recursive: true)
|
|
14
|
+
class FileSystem
|
|
15
|
+
# @param id [String] the machine's id.
|
|
16
|
+
def initialize(id)
|
|
17
|
+
@id = id
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Write +data+ to +path+ in the guest, creating parent directories.
|
|
21
|
+
#
|
|
22
|
+
# @param path [String] absolute path in the guest.
|
|
23
|
+
# @param data [String] text or binary content.
|
|
24
|
+
# @return [void]
|
|
25
|
+
# @raise [FileTransferFailed]
|
|
26
|
+
def write_file(path, data)
|
|
27
|
+
res = Process.run(["cp", "-", "#{@id}:#{path}"], stdin: data, binary: true)
|
|
28
|
+
check!(res, path)
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Read +path+ from the guest as bytes (ASCII-8BIT).
|
|
33
|
+
#
|
|
34
|
+
# @param path [String]
|
|
35
|
+
# @return [String] binary string.
|
|
36
|
+
# @raise [FileTransferFailed]
|
|
37
|
+
def read_file(path)
|
|
38
|
+
res = Process.run(["cp", "#{@id}:#{path}", "-"], binary: true)
|
|
39
|
+
check!(res, path)
|
|
40
|
+
res.stdout
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Read +path+ from the guest and tag it with +encoding+.
|
|
44
|
+
#
|
|
45
|
+
# @param path [String]
|
|
46
|
+
# @param encoding [String]
|
|
47
|
+
# @return [String]
|
|
48
|
+
def read_text(path, encoding: "UTF-8")
|
|
49
|
+
read_file(path).force_encoding(encoding)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Copy a host file or directory into the guest.
|
|
53
|
+
#
|
|
54
|
+
# A directory's *contents* land in +remote_path+, so
|
|
55
|
+
# <tt>upload("./src", "/app/src")</tt> leaves the guest's +/app/src+ holding
|
|
56
|
+
# what +./src+ holds. Whether it recurses is decided by looking at the local
|
|
57
|
+
# path, so callers do not have to say which kind of thing it is.
|
|
58
|
+
#
|
|
59
|
+
# @param local_path [String]
|
|
60
|
+
# @param remote_path [String]
|
|
61
|
+
# @return [void]
|
|
62
|
+
# @raise [FileTransferFailed]
|
|
63
|
+
def upload(local_path, remote_path)
|
|
64
|
+
unless File.exist?(local_path)
|
|
65
|
+
raise FileTransferFailed.new("cannot upload #{local_path}: no such file or directory",
|
|
66
|
+
local_path)
|
|
67
|
+
end
|
|
68
|
+
args = ["cp"]
|
|
69
|
+
args << "-r" if File.directory?(local_path)
|
|
70
|
+
args += [local_path.to_s, "#{@id}:#{remote_path}"]
|
|
71
|
+
check!(Process.run(args), local_path)
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Copy a file or directory out of the guest onto the host.
|
|
76
|
+
#
|
|
77
|
+
# Pass <tt>recursive: true</tt> for a directory; unlike {#upload} it cannot
|
|
78
|
+
# be detected here, because the path lives in the guest and answering would
|
|
79
|
+
# cost an extra round trip.
|
|
80
|
+
#
|
|
81
|
+
# @param remote_path [String]
|
|
82
|
+
# @param local_path [String]
|
|
83
|
+
# @param recursive [Boolean]
|
|
84
|
+
# @return [void]
|
|
85
|
+
# @raise [FileTransferFailed]
|
|
86
|
+
def download(remote_path, local_path, recursive: false)
|
|
87
|
+
args = ["cp"]
|
|
88
|
+
args << "-r" if recursive
|
|
89
|
+
args += ["#{@id}:#{remote_path}", local_path.to_s]
|
|
90
|
+
check!(Process.run(args), remote_path)
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def check!(res, path)
|
|
97
|
+
return if res.exit_code.zero?
|
|
98
|
+
|
|
99
|
+
# The CLI already explains these well; strip its "Error: " prefix.
|
|
100
|
+
text = res.stderr.to_s.strip.sub(/\AError:\s*/, "")
|
|
101
|
+
text = "file transfer failed for #{path}" if text.empty?
|
|
102
|
+
raise FileTransferFailed.new(text, path)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
data/lib/bsdkrun/process.rb
CHANGED
|
@@ -26,15 +26,23 @@ module Bsdkrun
|
|
|
26
26
|
# @param env [Hash] extra environment merged onto the process env.
|
|
27
27
|
# @param stdin [String, nil] data piped to the child's stdin.
|
|
28
28
|
# @param log_level [Integer] bsdkrun global log level (0=off .. 5=trace).
|
|
29
|
+
# @param binary [Boolean] keep stdout as bytes (ASCII-8BIT) instead of text.
|
|
30
|
+
# Needed by {FileSystem#read_file}: appending a chunk of arbitrary bytes to
|
|
31
|
+
# a UTF-8 buffer raises Encoding::CompatibilityError, so a PNG read back
|
|
32
|
+
# out of a guest would blow up mid-transfer rather than return.
|
|
29
33
|
# @return [RawResult]
|
|
30
|
-
def run(args, env: {}, stdin: nil, log_level: 0, on_stdout: nil, on_stderr: nil)
|
|
34
|
+
def run(args, env: {}, stdin: nil, log_level: 0, on_stdout: nil, on_stderr: nil, binary: false)
|
|
31
35
|
bin = Binary.resolve
|
|
32
36
|
full = ["--log-level", log_level.to_s, *args]
|
|
33
37
|
merged_env = env.to_h.transform_keys(&:to_s).transform_values(&:to_s)
|
|
34
|
-
out = +""
|
|
38
|
+
out = binary ? (+"").b : +""
|
|
35
39
|
err = +""
|
|
36
40
|
status = nil
|
|
37
41
|
Open3.popen3(merged_env, bin, *full) do |child_in, child_out, child_err, wait|
|
|
42
|
+
if binary
|
|
43
|
+
child_in.binmode
|
|
44
|
+
child_out.binmode
|
|
45
|
+
end
|
|
38
46
|
writer = Thread.new { child_in.write(stdin) if stdin; child_in.close }
|
|
39
47
|
stdout_reader = Thread.new do
|
|
40
48
|
while (chunk = child_out.readpartial(8192) rescue nil)
|
data/lib/bsdkrun/sandbox.rb
CHANGED
|
@@ -9,9 +9,9 @@ module Bsdkrun
|
|
|
9
9
|
# with {Sandbox.list}.
|
|
10
10
|
#
|
|
11
11
|
# @example
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
12
|
+
# sbx = Bsdkrun::Sandbox.create(os: "linux", image: "alpine")
|
|
13
|
+
# sbx.exec(["uname", "-a"]).text
|
|
14
|
+
# sbx.stop
|
|
15
15
|
class Sandbox
|
|
16
16
|
ID_RE = /\A[0-9a-f]{6,}\z/
|
|
17
17
|
SSH_PORT_RE = /ssh -p (\d+)/
|
|
@@ -32,6 +32,18 @@ module Bsdkrun
|
|
|
32
32
|
@ssh_port = ssh_port
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
# Read and write files in the guest.
|
|
36
|
+
# @return [FileSystem]
|
|
37
|
+
def fs
|
|
38
|
+
@fs ||= FileSystem.new(@id)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Save and restore guest directories under a key.
|
|
42
|
+
# @return [Cache]
|
|
43
|
+
def cache
|
|
44
|
+
@cache ||= Cache.new(@id)
|
|
45
|
+
end
|
|
46
|
+
|
|
35
47
|
class << self
|
|
36
48
|
# Boot a new microVM and return a handle to it.
|
|
37
49
|
#
|
data/lib/bsdkrun/types.rb
CHANGED
|
@@ -34,7 +34,7 @@ module Bsdkrun
|
|
|
34
34
|
SandboxInfo = Data.define(
|
|
35
35
|
:id, :name, :image, :kind, :command, :status, :running, :exit_code,
|
|
36
36
|
:pid, :detached, :cpus, :mem, :volume, :state_dir, :network, :net_ip,
|
|
37
|
-
:created_at, :finished_at, :ports
|
|
37
|
+
:created_at, :finished_at, :ports, :origin
|
|
38
38
|
) do
|
|
39
39
|
# Map a +ps --json+ row (String keys) to a typed instance.
|
|
40
40
|
# @param row [Hash]
|
|
@@ -60,7 +60,8 @@ module Bsdkrun
|
|
|
60
60
|
net_ip: row["net_ip"],
|
|
61
61
|
created_at: row["created_at"].to_i,
|
|
62
62
|
finished_at: to_i_or_nil(row["finished_at"]),
|
|
63
|
-
ports: (row["ports"] || []).map { |p| PortForward.from_row(p) }
|
|
63
|
+
ports: (row["ports"] || []).map { |p| PortForward.from_row(p) },
|
|
64
|
+
origin: row["origin"]
|
|
64
65
|
)
|
|
65
66
|
end
|
|
66
67
|
|
|
@@ -96,11 +97,190 @@ module Bsdkrun
|
|
|
96
97
|
net_ip: m["netIp"],
|
|
97
98
|
created_at: m["createdAt"].to_i,
|
|
98
99
|
finished_at: to_i_or_nil(m["finishedAt"]),
|
|
99
|
-
ports: (m["ports"] || []).map { |p| PortForward.from_row(p) }
|
|
100
|
+
ports: (m["ports"] || []).map { |p| PortForward.from_row(p) },
|
|
101
|
+
origin: m["origin"]
|
|
100
102
|
)
|
|
101
103
|
end
|
|
102
104
|
end
|
|
103
105
|
|
|
106
|
+
# A machine snapshot: one machine's disk state, captured under a name.
|
|
107
|
+
#
|
|
108
|
+
# A copy-on-write clone rather than a memory image — the files the guest
|
|
109
|
+
# wrote, not what it was executing. {Bsdkrun::Client#branch} boots a new
|
|
110
|
+
# machine from one; {Bsdkrun::Client#restore} puts one back.
|
|
111
|
+
SnapshotInfo = Data.define(
|
|
112
|
+
:id, :name, :machine_id, :machine_name, :kind, :image, :path, :parent,
|
|
113
|
+
:description, :cpus, :mem, :ports, :size, :created_at
|
|
114
|
+
) do
|
|
115
|
+
# Map a GraphQL +Snapshot+ (camelCase) to a typed instance.
|
|
116
|
+
# @param s [Hash]
|
|
117
|
+
# @return [SnapshotInfo]
|
|
118
|
+
def self.from_graphql(s)
|
|
119
|
+
new(
|
|
120
|
+
id: s["id"].to_s,
|
|
121
|
+
name: s["name"].to_s,
|
|
122
|
+
machine_id: s["machineId"].to_s,
|
|
123
|
+
machine_name: (s["machineName"] || "").to_s,
|
|
124
|
+
kind: s["kind"].to_s,
|
|
125
|
+
image: (s["image"] || "").to_s,
|
|
126
|
+
path: (s["path"] || "").to_s,
|
|
127
|
+
parent: s["parent"],
|
|
128
|
+
description: (s["description"] || "").to_s,
|
|
129
|
+
cpus: s["cpus"].to_i,
|
|
130
|
+
mem: s["mem"].to_i,
|
|
131
|
+
ports: (s["ports"] || []).map { |p| PortForward.from_row(p) },
|
|
132
|
+
size: s["size"],
|
|
133
|
+
created_at: s["createdAt"].to_i
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Map a +bsdkrun snapshots --json+ row (snake_case).
|
|
138
|
+
# @param row [Hash]
|
|
139
|
+
# @return [SnapshotInfo]
|
|
140
|
+
def self.from_row(row)
|
|
141
|
+
new(
|
|
142
|
+
id: row["id"].to_s,
|
|
143
|
+
name: row["name"].to_s,
|
|
144
|
+
machine_id: row["machine_id"].to_s,
|
|
145
|
+
machine_name: (row["machine_name"] || "").to_s,
|
|
146
|
+
kind: row["kind"].to_s,
|
|
147
|
+
image: (row["image"] || "").to_s,
|
|
148
|
+
path: (row["path"] || "").to_s,
|
|
149
|
+
parent: row["parent"],
|
|
150
|
+
description: (row["description"] || "").to_s,
|
|
151
|
+
cpus: row["cpus"].to_i,
|
|
152
|
+
mem: row["mem"].to_i,
|
|
153
|
+
ports: (row["ports"] || []).map { |p| PortForward.from_row(p) },
|
|
154
|
+
size: row["size"],
|
|
155
|
+
created_at: row["created_at"].to_i
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# A coding agent bsdkrun can sandbox.
|
|
161
|
+
#
|
|
162
|
+
# Each runs in a disposable microVM with a persistent login, a shared skills
|
|
163
|
+
# store, and only the folder you choose to share.
|
|
164
|
+
AiAgent = Data.define(
|
|
165
|
+
:id, :label, :flavor, :description, :installed, :running
|
|
166
|
+
) do
|
|
167
|
+
# @param a [Hash]
|
|
168
|
+
# @return [AiAgent]
|
|
169
|
+
def self.from_graphql(a)
|
|
170
|
+
new(
|
|
171
|
+
id: a["id"].to_s,
|
|
172
|
+
label: a["label"].to_s,
|
|
173
|
+
flavor: a["flavor"].to_s,
|
|
174
|
+
description: (a["description"] || "").to_s,
|
|
175
|
+
installed: !!a["installed"],
|
|
176
|
+
running: a["running"].to_i
|
|
177
|
+
)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
class << self
|
|
181
|
+
alias from_row from_graphql
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# One agent sandbox. It is a machine, so +logs+/+stop+ work on +id+.
|
|
186
|
+
AiSession = Data.define(
|
|
187
|
+
:id, :name, :agent, :running, :workspace, :created_at
|
|
188
|
+
) do
|
|
189
|
+
# @param s [Hash]
|
|
190
|
+
# @return [AiSession]
|
|
191
|
+
def self.from_graphql(s)
|
|
192
|
+
new(
|
|
193
|
+
id: s["id"].to_s,
|
|
194
|
+
name: s["name"].to_s,
|
|
195
|
+
agent: s["agent"].to_s,
|
|
196
|
+
running: !!s["running"],
|
|
197
|
+
workspace: s["workspace"],
|
|
198
|
+
created_at: (s["createdAt"] || s["created_at"]).to_i
|
|
199
|
+
)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
class << self
|
|
203
|
+
alias from_row from_graphql
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# The Docker engine VM: whether it is up, and how to reach it.
|
|
208
|
+
#
|
|
209
|
+
# bsdkrun runs one +docker:dind+ microVM and serves its API on a host unix
|
|
210
|
+
# socket, so the host's own +docker+ CLI drives the same engine.
|
|
211
|
+
DockerStatus = Data.define(
|
|
212
|
+
:running, :machine_id, :machine_running, :socket, :socket_ready, :api_port,
|
|
213
|
+
:version, :containers, :images, :mounts, :disk, :disk_size
|
|
214
|
+
) do
|
|
215
|
+
# @param s [Hash] a GraphQL +DockerStatus+ (camelCase).
|
|
216
|
+
# @return [DockerStatus]
|
|
217
|
+
def self.from_graphql(s)
|
|
218
|
+
new(
|
|
219
|
+
running: !!s["running"],
|
|
220
|
+
machine_id: s["machineId"],
|
|
221
|
+
machine_running: !!s["machineRunning"],
|
|
222
|
+
socket: s["socket"].to_s,
|
|
223
|
+
socket_ready: !!s["socketReady"],
|
|
224
|
+
api_port: to_i_or_nil(s["apiPort"]),
|
|
225
|
+
version: s["version"],
|
|
226
|
+
containers: to_i_or_nil(s["containers"]),
|
|
227
|
+
images: to_i_or_nil(s["images"]),
|
|
228
|
+
mounts: Array(s["mounts"]),
|
|
229
|
+
disk: s["disk"],
|
|
230
|
+
disk_size: to_i_or_nil(s["diskSize"])
|
|
231
|
+
)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# @param row [Hash] a +bsdkrun docker status --json+ row (snake_case).
|
|
235
|
+
# @return [DockerStatus]
|
|
236
|
+
def self.from_row(row)
|
|
237
|
+
new(
|
|
238
|
+
running: !!row["running"],
|
|
239
|
+
machine_id: row["machine_id"],
|
|
240
|
+
machine_running: !!row["machine_running"],
|
|
241
|
+
socket: row["socket"].to_s,
|
|
242
|
+
socket_ready: !!row["socket_ready"],
|
|
243
|
+
api_port: to_i_or_nil(row["api_port"]),
|
|
244
|
+
version: row["version"],
|
|
245
|
+
containers: to_i_or_nil(row["containers"]),
|
|
246
|
+
images: to_i_or_nil(row["images"]),
|
|
247
|
+
mounts: Array(row["mounts"]),
|
|
248
|
+
disk: row["disk"],
|
|
249
|
+
disk_size: to_i_or_nil(row["disk_size"])
|
|
250
|
+
)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# A container in the Docker engine VM — a trimmed +docker ps+ row.
|
|
255
|
+
DockerContainer = Data.define(
|
|
256
|
+
:id, :name, :image, :command, :state, :status, :ports, :created
|
|
257
|
+
) do
|
|
258
|
+
# @return [Boolean] whether the container is up.
|
|
259
|
+
def running?
|
|
260
|
+
state == "running"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Both the GraphQL object and +docker ps --json+ use these field names.
|
|
264
|
+
# @param c [Hash]
|
|
265
|
+
# @return [DockerContainer]
|
|
266
|
+
def self.from_graphql(c)
|
|
267
|
+
new(
|
|
268
|
+
id: c["id"].to_s,
|
|
269
|
+
name: c["name"].to_s,
|
|
270
|
+
image: c["image"].to_s,
|
|
271
|
+
command: (c["command"] || "").to_s,
|
|
272
|
+
state: c["state"].to_s,
|
|
273
|
+
status: c["status"].to_s,
|
|
274
|
+
ports: Array(c["ports"]),
|
|
275
|
+
created: c["created"].to_i
|
|
276
|
+
)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
class << self
|
|
280
|
+
alias from_row from_graphql
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
104
284
|
# An image as reported by +bsdkrun images --json+.
|
|
105
285
|
ImageInfo = Data.define(:id, :reference, :digest, :size, :rootfs, :created_at) do
|
|
106
286
|
# @param row [Hash]
|
data/lib/bsdkrun/version.rb
CHANGED
data/lib/bsdkrun.rb
CHANGED
|
@@ -5,6 +5,8 @@ require_relative "bsdkrun/errors"
|
|
|
5
5
|
require_relative "bsdkrun/binary"
|
|
6
6
|
require_relative "bsdkrun/process"
|
|
7
7
|
require_relative "bsdkrun/args"
|
|
8
|
+
require_relative "bsdkrun/cache"
|
|
9
|
+
require_relative "bsdkrun/filesystem"
|
|
8
10
|
require_relative "bsdkrun/types"
|
|
9
11
|
require_relative "bsdkrun/sandbox"
|
|
10
12
|
require_relative "bsdkrun/images"
|
|
@@ -23,9 +25,9 @@ require_relative "bsdkrun/client"
|
|
|
23
25
|
# @example
|
|
24
26
|
# require "bsdkrun"
|
|
25
27
|
#
|
|
26
|
-
#
|
|
27
|
-
# puts
|
|
28
|
-
#
|
|
28
|
+
# sbx = Bsdkrun::Sandbox.create(os: "linux", image: "alpine")
|
|
29
|
+
# puts sbx.exec(["uname", "-a"]).text
|
|
30
|
+
# sbx.stop
|
|
29
31
|
module Bsdkrun
|
|
30
32
|
class << self
|
|
31
33
|
# Force the SDK to use a specific +bsdkrun+ binary, bypassing discovery.
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: bsdkrun
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tsiry Sandratraina
|
|
@@ -51,8 +51,10 @@ files:
|
|
|
51
51
|
- lib/bsdkrun.rb
|
|
52
52
|
- lib/bsdkrun/args.rb
|
|
53
53
|
- lib/bsdkrun/binary.rb
|
|
54
|
+
- lib/bsdkrun/cache.rb
|
|
54
55
|
- lib/bsdkrun/client.rb
|
|
55
56
|
- lib/bsdkrun/errors.rb
|
|
57
|
+
- lib/bsdkrun/filesystem.rb
|
|
56
58
|
- lib/bsdkrun/images.rb
|
|
57
59
|
- lib/bsdkrun/networks.rb
|
|
58
60
|
- lib/bsdkrun/process.rb
|