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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 14072fa1b965040c136001ee65a8ef384205cbc119ef91658c387e2ed84100fb
4
- data.tar.gz: c3981e13c6a964c084b3a1b54a051801ab373eda107f74dd869e789242c744df
3
+ metadata.gz: d1b2ffdacfec58cee3714b8ca96ecc133604655d423e264444d8f41ea88393e5
4
+ data.tar.gz: 848ec7c03c5489a41daf404135c3bbfdcfb126449ecb712ddf920ea7377bc822
5
5
  SHA512:
6
- metadata.gz: 5dcbfd7f46c6e412acc0eccc6ccbe639fdffaf875a8ac56e8af0bbac4ec4654804a149f8eff78316ad0ca06cf2338414de320cf6ba05ca8a8ea11d579167c92e
7
- data.tar.gz: 165af797cc3af4b111c9fad1c02f8f1b788d1e900e1d49715e8547b02e8849b768b21ed3cc81d4f922accb073b46f33f7400d7ff854f5868de92869802d3699a
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
- box = Bsdkrun::Sandbox.create(os: "linux", image: "alpine")
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 box.exec(["uname", "-a"]).text
16
- box.exec(["apk", "add", "curl"], throw_on_error: true)
17
- box.run_command("curl", ["-fsSL", "https://example.com"])
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
- box.stop
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
- box.exec(["ls", "-la", "/etc"])
105
+ sbx.exec(["ls", "-la", "/etc"])
87
106
 
88
- box.exec("ruby",
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 = box.run_command("uname", ["-a"])
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
- box = Bsdkrun::Sandbox.create(os: "linux", image: "alpine", command: ["sleep", "300"])
118
- same = Bsdkrun::Sandbox.get(box.id) # reconnect (prefix ok)
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
- box.status # SandboxInfo | nil
122
- box.running? # true / false
123
- box.logs # console log (String)
124
- box.shell # interactive shell (inherits the terminal)
125
- box.stop # BSD guests clean-poweroff; Linux SIGTERM
126
- box.start # restart in place — resumes its own disk/rootfs (data persists)
127
- box.update(cpus: 4, mem: 2048) # applies on next start
128
- box.remove(force: true)
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
- box.ssh_setup # install local ~/.ssh/*.pub keys
151
- box.ssh_setup(user: "tsiry", key: "~/.ssh/work.pub")
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
- box.tailscale_up(authkey: "tskey-auth-...", hostname: "web")
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
@@ -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,
@@ -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
@@ -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)
@@ -9,9 +9,9 @@ module Bsdkrun
9
9
  # with {Sandbox.list}.
10
10
  #
11
11
  # @example
12
- # box = Bsdkrun::Sandbox.create(os: "linux", image: "alpine")
13
- # box.exec(["uname", "-a"]).text
14
- # box.stop
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]
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Bsdkrun
4
4
  # The bsdkrun Ruby SDK version.
5
- VERSION = "0.3.2"
5
+ VERSION = "0.5.0"
6
6
  end
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
- # box = Bsdkrun::Sandbox.create(os: "linux", image: "alpine")
27
- # puts box.exec(["uname", "-a"]).text
28
- # box.stop
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.3.2
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