bsdkrun 0.4.0 → 0.6.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 +31 -0
- data/lib/bsdkrun/ci.rb +204 -0
- data/lib/bsdkrun/client.rb +270 -1
- data/lib/bsdkrun/types.rb +183 -3
- data/lib/bsdkrun/version.rb +1 -1
- data/lib/bsdkrun.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 86e468aea9e96825db542d4c5e9ddf2fbbc473ddfb4e4b7edfa8ecac48abd685
|
|
4
|
+
data.tar.gz: 86f792adb80c6218763193a256b16c4f307e68063a494718277e4e4b94d3c0b1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e948aea05cab5bffabb28a50a0fb8df66500ab6b6ead6fe0e8a3ff9f07d981f84f19deac1f775e7c4a7665dd037a711d2c6aba19fb15ee4d1cdb7903f8293c40
|
|
7
|
+
data.tar.gz: 56d75e614b8f78319079302e61cc257cd30a3229e27384d09e6412c9bcb58459a6519d6df4e17a608a0b0fc18e92569e4510fea063bc849b13e60af3a723cea8
|
data/README.md
CHANGED
|
@@ -300,6 +300,37 @@ the new machine's id. `run_solo5` boots a MirageOS unikernel under the
|
|
|
300
300
|
`stop`/`start`/`remove`/`update`/`commit` return a
|
|
301
301
|
`CommandResult` (`exit_code`, `stdout`, `stderr`).
|
|
302
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
|
+
|
|
303
334
|
For a live terminal instead of a one-shot `exec`, use `shell`:
|
|
304
335
|
|
|
305
336
|
```ruby
|
data/lib/bsdkrun/ci.rb
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
|
|
7
|
+
module Bsdkrun
|
|
8
|
+
# CI workflows defined in code instead of YAML.
|
|
9
|
+
#
|
|
10
|
+
# The builder produces exactly the file +bsdkrun ci+ (and tangled's spindle)
|
|
11
|
+
# consumes — {CIWorkflow#yaml} is that file, {CIWorkflow#save} commits it to
|
|
12
|
+
# +.tangled/workflows/+, and {CIWorkflow#run} executes it in a microVM
|
|
13
|
+
# without a file ever touching the repository:
|
|
14
|
+
#
|
|
15
|
+
# Bsdkrun.workflow("test")
|
|
16
|
+
# .on_push("main")
|
|
17
|
+
# .deps("ruby", "bundler")
|
|
18
|
+
# .env("CI_FROM", "sdk")
|
|
19
|
+
# .step("install", "bundle install")
|
|
20
|
+
# .step("test", "bundle exec rspec")
|
|
21
|
+
# .run
|
|
22
|
+
#
|
|
23
|
+
# Code is the source of truth and YAML the wire format, in that order —
|
|
24
|
+
# which is why +save+ writes a generated-file header: a hand-edit there will
|
|
25
|
+
# be overwritten by the next +save+.
|
|
26
|
+
class CIWorkflow
|
|
27
|
+
def initialize(name)
|
|
28
|
+
@name = name
|
|
29
|
+
@engine = "nixery"
|
|
30
|
+
@when = []
|
|
31
|
+
@deps = {}
|
|
32
|
+
@env = {}
|
|
33
|
+
@steps = []
|
|
34
|
+
@clone_depth = nil
|
|
35
|
+
@clone_skip = false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Override the engine (+nixery+ by default).
|
|
39
|
+
def engine(engine)
|
|
40
|
+
@engine = engine
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Add a push trigger for the given branches.
|
|
45
|
+
def on_push(*branches)
|
|
46
|
+
@when << [["push"], branches]
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Add a pull_request trigger targeting the given branches.
|
|
51
|
+
def on_pull_request(*branches)
|
|
52
|
+
@when << [["pull_request"], branches]
|
|
53
|
+
self
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Add a trigger with explicit events.
|
|
57
|
+
def on(events, *branches)
|
|
58
|
+
@when << [events, branches]
|
|
59
|
+
self
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Add nixpkgs dependencies — the toolchain the steps run against.
|
|
63
|
+
def deps(*packages)
|
|
64
|
+
(@deps["nixpkgs"] ||= []).concat(packages)
|
|
65
|
+
self
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Add dependencies from a custom registry (a flake reference).
|
|
69
|
+
def deps_from(registry, *packages)
|
|
70
|
+
(@deps[registry] ||= []).concat(packages)
|
|
71
|
+
self
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Set a workflow-level environment variable.
|
|
75
|
+
def env(key, value)
|
|
76
|
+
@env[key] = value
|
|
77
|
+
self
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Append a step; steps run serially in one VM, from the workspace root.
|
|
81
|
+
def step(name, command, env: nil)
|
|
82
|
+
@steps << { name: name, command: command, env: env || {} }
|
|
83
|
+
self
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Set the clone depth (default 1).
|
|
87
|
+
def clone_depth(depth)
|
|
88
|
+
@clone_depth = depth
|
|
89
|
+
self
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Skip the checkout entirely.
|
|
93
|
+
def skip_clone
|
|
94
|
+
@clone_skip = true
|
|
95
|
+
self
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The workflow file name {#save} writes: +<name>.yml+.
|
|
99
|
+
def file_name
|
|
100
|
+
@name.match?(/\.ya?ml\z/) ? @name : "#{@name}.yml"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Render the workflow file. Scalars are emitted as JSON strings — valid
|
|
104
|
+
# YAML by construction — and commands as literal blocks when safe, so the
|
|
105
|
+
# SDK needs no YAML dependency.
|
|
106
|
+
def yaml
|
|
107
|
+
out = []
|
|
108
|
+
q = ->(s) { JSON.generate(s) }
|
|
109
|
+
|
|
110
|
+
unless @when.empty?
|
|
111
|
+
out << "when:"
|
|
112
|
+
@when.each do |events, branches|
|
|
113
|
+
out << " - event: [#{events.map(&q).join(', ')}]"
|
|
114
|
+
if branches.length == 1
|
|
115
|
+
out << " branch: #{q.call(branches[0])}"
|
|
116
|
+
elsif branches.length > 1
|
|
117
|
+
out << " branch: [#{branches.map(&q).join(', ')}]"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
out << ""
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
out << "engine: #{@engine}"
|
|
124
|
+
|
|
125
|
+
unless @deps.empty?
|
|
126
|
+
out << "" << "dependencies:"
|
|
127
|
+
@deps.keys.sort.each do |reg|
|
|
128
|
+
out << " #{q.call(reg)}:"
|
|
129
|
+
@deps[reg].each { |p| out << " - #{q.call(p)}" }
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
unless @env.empty?
|
|
134
|
+
out << "" << "environment:"
|
|
135
|
+
@env.keys.sort.each { |k| out << " #{k}: #{q.call(@env[k])}" }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
if @clone_skip || @clone_depth
|
|
139
|
+
out << "" << "clone:"
|
|
140
|
+
out << " skip: true" if @clone_skip
|
|
141
|
+
out << " depth: #{@clone_depth}" if @clone_depth
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
out << "" << "steps:"
|
|
145
|
+
@steps.each do |s|
|
|
146
|
+
out << " - name: #{q.call(s[:name])}"
|
|
147
|
+
# Literal blocks read well in a committed file, but cannot carry
|
|
148
|
+
# trailing spaces or carriage returns byte-for-byte; fall back to a
|
|
149
|
+
# JSON string rather than silently altering the command.
|
|
150
|
+
block_safe = !s[:command].empty? &&
|
|
151
|
+
!s[:command].include?("\r") &&
|
|
152
|
+
s[:command].split("\n", -1).all? { |l| l == l.sub(/ +\z/, "") }
|
|
153
|
+
if block_safe
|
|
154
|
+
out << " command: |"
|
|
155
|
+
s[:command].sub(/\n+\z/, "").split("\n", -1).each { |l| out << " #{l}" }
|
|
156
|
+
else
|
|
157
|
+
out << " command: #{q.call(s[:command])}"
|
|
158
|
+
end
|
|
159
|
+
next if s[:env].empty?
|
|
160
|
+
|
|
161
|
+
out << " environment:"
|
|
162
|
+
s[:env].keys.sort.each { |k| out << " #{k}: #{q.call(s[:env][k])}" }
|
|
163
|
+
end
|
|
164
|
+
"#{out.join("\n")}\n"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Write into +<repo>/.tangled/workflows/+ and return the path.
|
|
168
|
+
def save(repo)
|
|
169
|
+
dir = File.join(repo, ".tangled", "workflows")
|
|
170
|
+
FileUtils.mkdir_p(dir)
|
|
171
|
+
path = File.join(dir, file_name)
|
|
172
|
+
File.write(
|
|
173
|
+
path,
|
|
174
|
+
"# Generated by the bsdkrun SDK — edit the code that save()d it instead.\n#{yaml}"
|
|
175
|
+
)
|
|
176
|
+
path
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Execute the workflow in a microVM, streaming output. The YAML never
|
|
180
|
+
# touches the repository — it goes to a temp file and +bsdkrun ci run -f+.
|
|
181
|
+
# Raises {CommandFailed} when a step fails.
|
|
182
|
+
def run(dir: nil)
|
|
183
|
+
Dir.mktmpdir("bsdkrun-ci-") do |tmp|
|
|
184
|
+
file = File.join(tmp, file_name)
|
|
185
|
+
File.write(file, yaml)
|
|
186
|
+
args = ["ci", "run", "-f", file]
|
|
187
|
+
args += ["-w", dir] if dir
|
|
188
|
+
ok = Process.spawn_interactive(args)
|
|
189
|
+
unless ok
|
|
190
|
+
raise CommandFailed.new(
|
|
191
|
+
exit_code: 1, stdout: "", stderr: "workflow #{@name} failed",
|
|
192
|
+
command: "bsdkrun ci run"
|
|
193
|
+
)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
nil
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Start a CI workflow definition.
|
|
201
|
+
def self.workflow(name)
|
|
202
|
+
CIWorkflow.new(name)
|
|
203
|
+
end
|
|
204
|
+
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.
|
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
|
@@ -6,6 +6,7 @@ require_relative "bsdkrun/binary"
|
|
|
6
6
|
require_relative "bsdkrun/process"
|
|
7
7
|
require_relative "bsdkrun/args"
|
|
8
8
|
require_relative "bsdkrun/cache"
|
|
9
|
+
require_relative "bsdkrun/ci"
|
|
9
10
|
require_relative "bsdkrun/filesystem"
|
|
10
11
|
require_relative "bsdkrun/types"
|
|
11
12
|
require_relative "bsdkrun/sandbox"
|
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.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tsiry Sandratraina
|
|
@@ -52,6 +52,7 @@ files:
|
|
|
52
52
|
- lib/bsdkrun/args.rb
|
|
53
53
|
- lib/bsdkrun/binary.rb
|
|
54
54
|
- lib/bsdkrun/cache.rb
|
|
55
|
+
- lib/bsdkrun/ci.rb
|
|
55
56
|
- lib/bsdkrun/client.rb
|
|
56
57
|
- lib/bsdkrun/errors.rb
|
|
57
58
|
- lib/bsdkrun/filesystem.rb
|