bsdkrun 0.1.0 → 0.3.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 +70 -4
- data/lib/bsdkrun/args.rb +66 -0
- data/lib/bsdkrun/client.rb +564 -0
- data/lib/bsdkrun/errors.rb +25 -0
- data/lib/bsdkrun/shell_session.rb +139 -0
- data/lib/bsdkrun/types.rb +94 -2
- data/lib/bsdkrun/version.rb +1 -1
- data/lib/bsdkrun/websocket_frame.rb +116 -0
- data/lib/bsdkrun/ws_client.rb +315 -0
- data/lib/bsdkrun.rb +5 -1
- metadata +8 -4
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
require "base64"
|
|
7
|
+
|
|
8
|
+
require_relative "errors"
|
|
9
|
+
require_relative "types"
|
|
10
|
+
require_relative "ws_client"
|
|
11
|
+
require_relative "shell_session"
|
|
12
|
+
|
|
13
|
+
module Bsdkrun
|
|
14
|
+
# A client that talks to a remote +bsdkrund+ daemon's GraphQL API directly
|
|
15
|
+
# — HTTP for queries/mutations, a hand-rolled +graphql-transport-ws+ socket
|
|
16
|
+
# for subscriptions — instead of shelling out to a local +bsdkrun+ binary
|
|
17
|
+
# the way {Bsdkrun::Sandbox} does.
|
|
18
|
+
#
|
|
19
|
+
# The wire contract (URL/header shape, error mapping, subscription
|
|
20
|
+
# protocol, field names) is locked to match the other bsdkrun SDKs
|
|
21
|
+
# (TypeScript/Python/Elixir/Gleam) and the web frontend's
|
|
22
|
+
# +web/src/lib/graphql.ts+ byte-for-byte — see that file for the reference
|
|
23
|
+
# implementation this one mirrors.
|
|
24
|
+
#
|
|
25
|
+
# @example
|
|
26
|
+
# client = Bsdkrun::Client.from_env
|
|
27
|
+
# client.list.each { |m| puts m.id }
|
|
28
|
+
# result = client.exec(id, ["uname", "-a"])
|
|
29
|
+
# puts result.output
|
|
30
|
+
class Client
|
|
31
|
+
URL_ENV = "BSDKRUN_URL"
|
|
32
|
+
TOKEN_ENV = "BSDKRUN_TOKEN"
|
|
33
|
+
|
|
34
|
+
# The +Machine+ field selection shared by +machines+ / +machine+ — mirrors
|
|
35
|
+
# +web/src/lib/api.ts+'s +MACHINE_FIELDS+ fragment exactly.
|
|
36
|
+
MACHINE_FIELDS = <<~GQL.freeze
|
|
37
|
+
id name image kind command status running exitCode pid detached
|
|
38
|
+
cpus mem volume stateDir createdAt finishedAt network netIp
|
|
39
|
+
ports { bind host guest }
|
|
40
|
+
GQL
|
|
41
|
+
|
|
42
|
+
# @return [String] the GraphQL endpoint URL (normalized).
|
|
43
|
+
attr_reader :url
|
|
44
|
+
|
|
45
|
+
# @param url [String] the daemon's URL, e.g. "http://host:50052" or a
|
|
46
|
+
# full "http://host:50052/graphql" — normalized either way.
|
|
47
|
+
# @param token [String] the daemon's bearer token.
|
|
48
|
+
def initialize(url:, token:)
|
|
49
|
+
@url = self.class.normalize_url(url)
|
|
50
|
+
@token = token.to_s
|
|
51
|
+
@ws_mutex = Mutex.new
|
|
52
|
+
@ws = nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Build a client from +BSDKRUN_URL+ / +BSDKRUN_TOKEN+.
|
|
56
|
+
#
|
|
57
|
+
# A host set without a token is an error, not a silent fallback —
|
|
58
|
+
# mirrors +daemon/src/client.rs+'s +RemoteConfig::from_env+ (which uses
|
|
59
|
+
# +BSDKRUN_HOST+/+BSDKRUN_TOKEN+ for the gRPC client; these are
|
|
60
|
+
# GraphQL-specific env vars with a different URL shape, not aliases).
|
|
61
|
+
#
|
|
62
|
+
# @return [Client]
|
|
63
|
+
# @raise [Error] if +BSDKRUN_URL+ is unset, or set without +BSDKRUN_TOKEN+.
|
|
64
|
+
def self.from_env
|
|
65
|
+
url = ENV[URL_ENV]
|
|
66
|
+
raise Error, "#{URL_ENV} is not set; nothing to connect to" if url.nil? || url.strip.empty?
|
|
67
|
+
|
|
68
|
+
token = ENV[TOKEN_ENV]
|
|
69
|
+
if token.nil? || token.strip.empty?
|
|
70
|
+
raise Error, "#{URL_ENV} is set but #{TOKEN_ENV} is not"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
new(url: url, token: token)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Normalize a user-supplied URL into a full GraphQL endpoint: trim, add
|
|
77
|
+
# +http://+ if no scheme was given, strip trailing slashes, append
|
|
78
|
+
# +/graphql+ unless the path already ends with it. Mirrors
|
|
79
|
+
# +web/src/lib/connection.ts+'s +normalizeUrl+ exactly.
|
|
80
|
+
#
|
|
81
|
+
# @param input [String]
|
|
82
|
+
# @return [String]
|
|
83
|
+
def self.normalize_url(input)
|
|
84
|
+
s = input.to_s.strip
|
|
85
|
+
return s if s.empty?
|
|
86
|
+
|
|
87
|
+
s = "http://#{s}" unless s.match?(%r{\Ahttps?://}i)
|
|
88
|
+
s = s.sub(%r{/+\z}, "")
|
|
89
|
+
s = "#{s}/graphql" unless s.match?(%r{/graphql\z}i)
|
|
90
|
+
s
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Derive the websocket endpoint from the HTTP one: +http://+ -> +ws://+,
|
|
94
|
+
# +https://+ -> +wss://+, trailing slashes on the path stripped, +/ws+
|
|
95
|
+
# appended. Mirrors +web/src/lib/graphql.ts+'s +wsUrl+.
|
|
96
|
+
#
|
|
97
|
+
# @param http_url [String]
|
|
98
|
+
# @return [String]
|
|
99
|
+
def self.ws_url(http_url)
|
|
100
|
+
uri = URI.parse(http_url)
|
|
101
|
+
uri.scheme = uri.scheme == "https" ? "wss" : "ws"
|
|
102
|
+
uri.path = "#{uri.path.to_s.sub(%r{/+\z}, "")}/ws"
|
|
103
|
+
uri.to_s
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# ---- HTTP transport: the escape hatch, and everything below is built on it ----
|
|
107
|
+
|
|
108
|
+
# Run an arbitrary query or mutation. Every typed method on this class is
|
|
109
|
+
# implemented in terms of this — it exists as a public escape hatch for
|
|
110
|
+
# documents this SDK has no typed wrapper for yet.
|
|
111
|
+
#
|
|
112
|
+
# @param query [String] a GraphQL document.
|
|
113
|
+
# @param variables [Hash]
|
|
114
|
+
# @return [Hash] +body["data"]+ (String-keyed, as parsed by +JSON.parse+).
|
|
115
|
+
# @raise [AuthError] on HTTP 401, or a GraphQL error with
|
|
116
|
+
# +extensions.code == "UNAUTHENTICATED"+.
|
|
117
|
+
# @raise [GraphQLError] on transport failure, a non-JSON response, or any
|
|
118
|
+
# other GraphQL error.
|
|
119
|
+
def request(query, variables = {})
|
|
120
|
+
uri = URI.parse(@url)
|
|
121
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
122
|
+
http.use_ssl = uri.scheme == "https"
|
|
123
|
+
|
|
124
|
+
req = Net::HTTP::Post.new(uri.request_uri.empty? ? "/" : uri.request_uri)
|
|
125
|
+
req["content-type"] = "application/json"
|
|
126
|
+
req["authorization"] = "Bearer #{@token}"
|
|
127
|
+
req.body = JSON.generate({ query: query, variables: variables })
|
|
128
|
+
|
|
129
|
+
begin
|
|
130
|
+
res = http.request(req)
|
|
131
|
+
rescue StandardError => e
|
|
132
|
+
raise GraphQLError, "cannot reach the bsdkrun daemon at #{@url} — #{e.message}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
raise AuthError if res.code.to_i == 401
|
|
136
|
+
|
|
137
|
+
body = begin
|
|
138
|
+
JSON.parse(res.body.to_s)
|
|
139
|
+
rescue JSON::ParserError
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
raise GraphQLError, "the daemon returned a non-JSON response (#{res.code})" if body.nil?
|
|
143
|
+
|
|
144
|
+
errors = body["errors"]
|
|
145
|
+
if errors.is_a?(Array) && !errors.empty?
|
|
146
|
+
first = errors.first
|
|
147
|
+
message = first["message"].to_s
|
|
148
|
+
code = first.is_a?(Hash) ? first.dig("extensions", "code") : nil
|
|
149
|
+
raise AuthError, message if code == "UNAUTHENTICATED"
|
|
150
|
+
raise GraphQLError.new(message, code)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
body["data"]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# ---- subscriptions ----------------------------------------------------
|
|
157
|
+
|
|
158
|
+
# Start a subscription over the shared websocket (opened lazily on first
|
|
159
|
+
# use). See {WsClient#subscribe} for the exact queueing/ack semantics.
|
|
160
|
+
#
|
|
161
|
+
# @param query [String]
|
|
162
|
+
# @param variables [Hash]
|
|
163
|
+
# @param on_next [#call]
|
|
164
|
+
# @param on_error [#call, nil]
|
|
165
|
+
# @param on_complete [#call, nil]
|
|
166
|
+
# @return [Proc] call to unsubscribe.
|
|
167
|
+
def subscribe(query, variables = {}, on_next:, on_error: nil, on_complete: nil)
|
|
168
|
+
ws.subscribe(query, variables, on_next: on_next, on_error: on_error, on_complete: on_complete)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# ---- lifecycle / listing -----------------------------------------------
|
|
172
|
+
|
|
173
|
+
# @param all [Boolean] include stopped machines too.
|
|
174
|
+
# @return [Array<SandboxInfo>]
|
|
175
|
+
def list(all: false)
|
|
176
|
+
data = request("query($all:Boolean!){ machines(all:$all){ #{MACHINE_FIELDS} } }", { all: all })
|
|
177
|
+
(data["machines"] || []).map { |m| SandboxInfo.from_graphql(m) }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# @param id [String] id, name, or unique id prefix.
|
|
181
|
+
# @return [SandboxInfo, nil] nil if no such machine exists.
|
|
182
|
+
def get(id)
|
|
183
|
+
data = request("query($id:String!){ machine(id:$id){ #{MACHINE_FIELDS} } }", { id: id })
|
|
184
|
+
m = data["machine"]
|
|
185
|
+
m && SandboxInfo.from_graphql(m)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# @param id [String]
|
|
189
|
+
# @return [CommandResult]
|
|
190
|
+
def stop(id)
|
|
191
|
+
run_command_mutation(
|
|
192
|
+
"stopMachine",
|
|
193
|
+
"mutation($id:String!){ stopMachine(id:$id){ exitCode stdout stderr } }",
|
|
194
|
+
{ id: id }
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# @param id [String]
|
|
199
|
+
# @return [CommandResult]
|
|
200
|
+
def start(id)
|
|
201
|
+
run_command_mutation(
|
|
202
|
+
"startMachine",
|
|
203
|
+
"mutation($id:String!){ startMachine(id:$id){ exitCode stdout stderr } }",
|
|
204
|
+
{ id: id }
|
|
205
|
+
)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# @param ids [String, Array<String>]
|
|
209
|
+
# @param force [Boolean]
|
|
210
|
+
# @return [CommandResult]
|
|
211
|
+
def remove(ids, force: false)
|
|
212
|
+
run_command_mutation(
|
|
213
|
+
"removeMachines",
|
|
214
|
+
"mutation($ids:[String!]!,$force:Boolean!){ removeMachines(ids:$ids, force:$force){ exitCode stdout stderr } }",
|
|
215
|
+
{ ids: Array(ids), force: force }
|
|
216
|
+
)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# @param id [String]
|
|
220
|
+
# @param cpus [Integer, nil]
|
|
221
|
+
# @param mem [Integer, nil]
|
|
222
|
+
# @return [CommandResult]
|
|
223
|
+
def update(id, cpus: nil, mem: nil)
|
|
224
|
+
run_command_mutation(
|
|
225
|
+
"updateMachine",
|
|
226
|
+
"mutation($id:String!,$cpus:Int,$mem:Int){ updateMachine(id:$id, cpus:$cpus, mem:$mem){ exitCode stdout stderr } }",
|
|
227
|
+
{ id: id, cpus: cpus, mem: mem }
|
|
228
|
+
)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Snapshot a machine into a named flavor, like +docker commit+.
|
|
232
|
+
# @param id [String]
|
|
233
|
+
# @param name [String]
|
|
234
|
+
# @param description [String]
|
|
235
|
+
# @return [CommandResult]
|
|
236
|
+
def commit(id, name, description: "")
|
|
237
|
+
run_command_mutation(
|
|
238
|
+
"commitMachine",
|
|
239
|
+
"mutation($id:String!,$name:String!,$description:String!){ " \
|
|
240
|
+
"commitMachine(id:$id, name:$name, description:$description){ exitCode stdout stderr } }",
|
|
241
|
+
{ id: id, name: name, description: description }
|
|
242
|
+
)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# One-shot console log fetch. Use {#follow_logs} to stream instead.
|
|
246
|
+
# @param id [String]
|
|
247
|
+
# @param boot [Boolean] bsdkrun's own boot log instead of the guest console.
|
|
248
|
+
# @return [String]
|
|
249
|
+
def logs(id, boot: false)
|
|
250
|
+
data = request("query($id:String!,$boot:Boolean!){ machineLogs(id:$id, boot:$boot) }",
|
|
251
|
+
{ id: id, boot: boot })
|
|
252
|
+
data["machineLogs"]
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Stream a machine's console log live.
|
|
256
|
+
# @param id [String]
|
|
257
|
+
# @param follow [Boolean]
|
|
258
|
+
# @param boot [Boolean]
|
|
259
|
+
# @yieldparam data [String] binary-safe decoded chunk.
|
|
260
|
+
# @return [Proc] call to stop following.
|
|
261
|
+
def follow_logs(id, follow: true, boot: false, &on_data)
|
|
262
|
+
subscribe(
|
|
263
|
+
"subscription($id:String!,$follow:Boolean!,$boot:Boolean!){ " \
|
|
264
|
+
"machineLogs(id:$id, follow:$follow, boot:$boot){ dataBase64 exitCode } }",
|
|
265
|
+
{ id: id, follow: follow, boot: boot },
|
|
266
|
+
on_next: lambda { |data|
|
|
267
|
+
payload = data && data["machineLogs"]
|
|
268
|
+
next unless payload && payload["dataBase64"]
|
|
269
|
+
|
|
270
|
+
on_data&.call(Base64.decode64(payload["dataBase64"]))
|
|
271
|
+
}
|
|
272
|
+
)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# ---- booting ------------------------------------------------------------
|
|
276
|
+
#
|
|
277
|
+
# Seven variants, one per daemon mutation — see +daemon/src/graphql.rs+ for
|
|
278
|
+
# the exact input field names this transcribes (RunLinuxInput ~L384,
|
|
279
|
+
# RunBsdInput ~L406, RunNanosInput ~L429, RunUnikraftInput ~L445,
|
|
280
|
+
# RunSolo5Input ~L465, RunOsvInput ~L498, RunFlavorInput ~L541,
|
|
281
|
+
# NetInput ~L360). Each accepts
|
|
282
|
+
# an options Hash and/or keyword arguments, merged — the same convention
|
|
283
|
+
# {Sandbox.create} uses — with snake_case Ruby keys mapped 1:1 to the
|
|
284
|
+
# camelCase GraphQL fields on the wire.
|
|
285
|
+
|
|
286
|
+
# @return [String] the new machine's id.
|
|
287
|
+
def run_linux(opts = {}, **kwargs)
|
|
288
|
+
o = merge_opts(opts, kwargs)
|
|
289
|
+
input = {
|
|
290
|
+
image: o.fetch(:image),
|
|
291
|
+
cpus: o[:cpus],
|
|
292
|
+
mem: o[:mem],
|
|
293
|
+
net: net_input(o[:net]),
|
|
294
|
+
volume: o[:volume],
|
|
295
|
+
mounts: o[:mounts] || [],
|
|
296
|
+
env: o[:env] || [],
|
|
297
|
+
entrypoint: o[:entrypoint],
|
|
298
|
+
initramfs: o[:initramfs] || false,
|
|
299
|
+
kernel: o[:kernel],
|
|
300
|
+
kernelVersion: o[:kernel_version],
|
|
301
|
+
console: o[:console],
|
|
302
|
+
repo: o[:repo],
|
|
303
|
+
command: o[:command] || []
|
|
304
|
+
}
|
|
305
|
+
request("mutation($i:RunLinuxInput!){ runLinux(input:$i) }", { i: input })["runLinux"]
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# @return [String] the new machine's id.
|
|
309
|
+
def run_bsd(opts = {}, **kwargs)
|
|
310
|
+
o = merge_opts(opts, kwargs)
|
|
311
|
+
input = {
|
|
312
|
+
os: bsd_os_enum(o.fetch(:os)),
|
|
313
|
+
version: o[:version],
|
|
314
|
+
cpus: o[:cpus],
|
|
315
|
+
mem: o[:mem],
|
|
316
|
+
net: net_input(o[:net]),
|
|
317
|
+
volume: o[:volume],
|
|
318
|
+
persist: o[:persist] || false,
|
|
319
|
+
force: o[:force] || false,
|
|
320
|
+
firmware: o[:firmware],
|
|
321
|
+
attachDisk: o[:attach_disk] || [],
|
|
322
|
+
diskSize: o[:disk_size],
|
|
323
|
+
repo: o[:repo],
|
|
324
|
+
command: o[:command] || []
|
|
325
|
+
}
|
|
326
|
+
request("mutation($i:RunBsdInput!){ runBsd(input:$i) }", { i: input })["runBsd"]
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# No agent (no exec/shell), but it does have a root disk, so +persist:+
|
|
330
|
+
# is the one disk option it takes.
|
|
331
|
+
# @return [String] the new machine's id.
|
|
332
|
+
def run_nanos(opts = {}, **kwargs)
|
|
333
|
+
o = merge_opts(opts, kwargs)
|
|
334
|
+
input = {
|
|
335
|
+
image: o.fetch(:image),
|
|
336
|
+
cpus: o[:cpus],
|
|
337
|
+
mem: o[:mem],
|
|
338
|
+
net: net_input(o[:net]),
|
|
339
|
+
kernel: o[:kernel],
|
|
340
|
+
cmdline: o[:cmdline],
|
|
341
|
+
persist: o[:persist] || false
|
|
342
|
+
}
|
|
343
|
+
request("mutation($i:RunNanosInput!){ runNanos(input:$i) }", { i: input })["runNanos"]
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# A unikernel has no disk and no agent, so no volume/persist/repo/command
|
|
347
|
+
# fields — +mounts:+ (virtio-fs shares) is the exception, needing neither.
|
|
348
|
+
# @return [String] the new machine's id.
|
|
349
|
+
def run_unikraft(opts = {}, **kwargs)
|
|
350
|
+
o = merge_opts(opts, kwargs)
|
|
351
|
+
input = {
|
|
352
|
+
path: o[:path],
|
|
353
|
+
cpus: o[:cpus],
|
|
354
|
+
mem: o[:mem],
|
|
355
|
+
net: net_input(o[:net]),
|
|
356
|
+
cmdline: o[:cmdline],
|
|
357
|
+
initramfs: o[:initramfs],
|
|
358
|
+
mounts: o[:mounts] || []
|
|
359
|
+
}
|
|
360
|
+
request("mutation($i:RunUnikraftInput!){ runUnikraft(input:$i) }", { i: input })["runUnikraft"]
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Solo5 (MirageOS): runs under the +solo5-hvt+ tender rather than libkrun.
|
|
364
|
+
# The unikernel declares its own network and block devices in its +MFT1+
|
|
365
|
+
# manifest note, so only what the host alone can know is asked for —
|
|
366
|
+
# +block:+ backing files (+NAME=FILE+) and the unikernel's own +args:+
|
|
367
|
+
# (e.g. "--ipv4=10.0.0.2/24"). Like Unikraft, no disk and no agent, so no
|
|
368
|
+
# volume/persist/repo/command fields.
|
|
369
|
+
# @return [String] the new machine's id.
|
|
370
|
+
def run_solo5(opts = {}, **kwargs)
|
|
371
|
+
o = merge_opts(opts, kwargs)
|
|
372
|
+
input = {
|
|
373
|
+
path: o[:path],
|
|
374
|
+
cpus: o[:cpus],
|
|
375
|
+
mem: o[:mem],
|
|
376
|
+
net: net_input(o[:net]),
|
|
377
|
+
block: o[:block] || [],
|
|
378
|
+
args: o[:args] || []
|
|
379
|
+
}
|
|
380
|
+
request("mutation($i:RunSolo5Input!){ runSolo5(input:$i) }", { i: input })["runSolo5"]
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Like Nanos, no agent, but it does have a root filesystem, so the disk
|
|
384
|
+
# options apply — +disk:+ in particular, how an x86_64 guest gets a
|
|
385
|
+
# filesystem (its loader ELF is kernel only).
|
|
386
|
+
# @return [String] the new machine's id.
|
|
387
|
+
def run_osv(opts = {}, **kwargs)
|
|
388
|
+
o = merge_opts(opts, kwargs)
|
|
389
|
+
input = {
|
|
390
|
+
image: o.fetch(:image),
|
|
391
|
+
cpus: o[:cpus],
|
|
392
|
+
mem: o[:mem],
|
|
393
|
+
net: net_input(o[:net]),
|
|
394
|
+
cmdline: o[:cmdline],
|
|
395
|
+
disk: o[:disk],
|
|
396
|
+
noDisk: o[:no_disk] || false,
|
|
397
|
+
attachDisk: o[:attach_disk] || [],
|
|
398
|
+
gic: o[:gic],
|
|
399
|
+
persist: o[:persist] || false,
|
|
400
|
+
volume: o[:volume]
|
|
401
|
+
}
|
|
402
|
+
request("mutation($i:RunOsvInput!){ runOsv(input:$i) }", { i: input })["runOsv"]
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# @return [String] the new machine's id.
|
|
406
|
+
def run_flavor(opts = {}, **kwargs)
|
|
407
|
+
o = merge_opts(opts, kwargs)
|
|
408
|
+
input = {
|
|
409
|
+
name: o.fetch(:name),
|
|
410
|
+
cpus: o[:cpus],
|
|
411
|
+
mem: o[:mem],
|
|
412
|
+
ports: o[:ports] || [],
|
|
413
|
+
volume: o[:volume],
|
|
414
|
+
repo: o[:repo]
|
|
415
|
+
}
|
|
416
|
+
request("mutation($i:RunFlavorInput!){ runFlavor(input:$i) }", { i: input })["runFlavor"]
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# ---- exec / interactive shell ------------------------------------------
|
|
420
|
+
|
|
421
|
+
# Run a command to completion and collect its output. Implemented as the
|
|
422
|
+
# three-operation sequence +daemon/README.md+ documents: +openShell+
|
|
423
|
+
# (with a +command:+, so the session runs it instead of a login shell),
|
|
424
|
+
# THEN subscribe to +shellOutput+ (so nothing written in between is
|
|
425
|
+
# lost), THEN wait for an exit code. +closeShell+ runs in an +ensure+ so
|
|
426
|
+
# it happens whether the wait succeeded, failed, or raised.
|
|
427
|
+
#
|
|
428
|
+
# @param id [String] machine id.
|
|
429
|
+
# @param command [Array<String>] argv.
|
|
430
|
+
# @param env [Hash, Array<String>, nil] +"K=V"+ pairs, or a Hash of them.
|
|
431
|
+
# @return [ExecResult]
|
|
432
|
+
def exec(id, command, env: nil)
|
|
433
|
+
data = request(
|
|
434
|
+
"mutation($m:String!,$c:[String!]!,$e:[String!]!,$r:Int!,$k:Int!){ " \
|
|
435
|
+
"openShell(machineId:$m, command:$c, env:$e, rows:$r, cols:$k){ id } }",
|
|
436
|
+
{ m: id, c: Array(command), e: env_to_list(env), r: 24, k: 80 }
|
|
437
|
+
)
|
|
438
|
+
session_id = data["openShell"]["id"]
|
|
439
|
+
|
|
440
|
+
output = +"".b
|
|
441
|
+
exit_code = nil
|
|
442
|
+
done = Queue.new
|
|
443
|
+
|
|
444
|
+
unsubscribe = subscribe(
|
|
445
|
+
"subscription($s:String!){ shellOutput(sessionId:$s){ dataBase64 exitCode } }",
|
|
446
|
+
{ s: session_id },
|
|
447
|
+
on_next: lambda { |d|
|
|
448
|
+
payload = d && d["shellOutput"]
|
|
449
|
+
next unless payload
|
|
450
|
+
|
|
451
|
+
output << Base64.decode64(payload["dataBase64"]) if payload["dataBase64"]
|
|
452
|
+
unless payload["exitCode"].nil?
|
|
453
|
+
exit_code = payload["exitCode"]
|
|
454
|
+
done << :done
|
|
455
|
+
end
|
|
456
|
+
},
|
|
457
|
+
on_error: ->(e) { done << e },
|
|
458
|
+
on_complete: -> { done << :done }
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
begin
|
|
462
|
+
result = done.pop
|
|
463
|
+
raise result if result.is_a?(Exception)
|
|
464
|
+
ensure
|
|
465
|
+
unsubscribe.call
|
|
466
|
+
begin
|
|
467
|
+
request("mutation($s:String!){ closeShell(sessionId:$s) }", { s: session_id })
|
|
468
|
+
rescue GraphQLError
|
|
469
|
+
# closeShell is idempotent server-side; a request failure here
|
|
470
|
+
# (already gone, machine removed, etc.) must not mask the actual
|
|
471
|
+
# exec result/exception above — same as the other SDKs' Client#exec.
|
|
472
|
+
nil
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
ExecResult.new(exit_code: exit_code, output: output)
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# Open a live interactive session. Unlike {#exec}, this returns
|
|
480
|
+
# immediately with a handle whose {ShellSession#on_output} /
|
|
481
|
+
# {ShellSession#on_exit} callbacks fire as output arrives.
|
|
482
|
+
#
|
|
483
|
+
# @param id [String] machine id.
|
|
484
|
+
# @param command [Array<String>, nil] nil opens a login shell.
|
|
485
|
+
# @param env [Hash, Array<String>, nil]
|
|
486
|
+
# @param rows [Integer]
|
|
487
|
+
# @param cols [Integer]
|
|
488
|
+
# @return [ShellSession]
|
|
489
|
+
def shell(id, command: nil, env: nil, rows: 24, cols: 80)
|
|
490
|
+
data = request(
|
|
491
|
+
"mutation($m:String!,$c:[String!]!,$e:[String!]!,$r:Int!,$k:Int!){ " \
|
|
492
|
+
"openShell(machineId:$m, command:$c, env:$e, rows:$r, cols:$k){ id } }",
|
|
493
|
+
{ m: id, c: command.nil? ? [] : Array(command), e: env_to_list(env), r: rows, k: cols }
|
|
494
|
+
)
|
|
495
|
+
session_id = data["openShell"]["id"]
|
|
496
|
+
session = ShellSession.new(client: self, id: session_id)
|
|
497
|
+
|
|
498
|
+
unsubscribe = subscribe(
|
|
499
|
+
"subscription($s:String!){ shellOutput(sessionId:$s){ dataBase64 exitCode } }",
|
|
500
|
+
{ s: session_id },
|
|
501
|
+
on_next: lambda { |d|
|
|
502
|
+
payload = d && d["shellOutput"]
|
|
503
|
+
next unless payload
|
|
504
|
+
|
|
505
|
+
session.deliver_output(Base64.decode64(payload["dataBase64"])) if payload["dataBase64"]
|
|
506
|
+
session.deliver_exit(payload["exitCode"]) unless payload["exitCode"].nil?
|
|
507
|
+
},
|
|
508
|
+
on_error: ->(_e) { session.deliver_exit(nil) },
|
|
509
|
+
on_complete: -> {}
|
|
510
|
+
)
|
|
511
|
+
session.unsubscribe = unsubscribe
|
|
512
|
+
session
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
private
|
|
516
|
+
|
|
517
|
+
def ws
|
|
518
|
+
@ws_mutex.synchronize { @ws ||= WsClient.new(ws_url: self.class.ws_url(@url), token: @token) }
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def run_command_mutation(field, query, variables)
|
|
522
|
+
r = request(query, variables)[field]
|
|
523
|
+
CommandResult.new(exit_code: r["exitCode"].to_i, stdout: r["stdout"].to_s, stderr: r["stderr"].to_s)
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# Symbolize keys and merge a positional options Hash with keyword
|
|
527
|
+
# arguments — mirrors {Args.normalize}, which {Sandbox.create} uses.
|
|
528
|
+
def merge_opts(opts, kwargs)
|
|
529
|
+
opts.merge(kwargs).each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# @param net [Hash, nil] +:no_net+/+:ports+/+:mac+/+:network+/+:name+.
|
|
533
|
+
# @return [Hash, nil] a +NetInput+-shaped wire hash.
|
|
534
|
+
def net_input(net)
|
|
535
|
+
return nil unless net
|
|
536
|
+
|
|
537
|
+
n = net.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
538
|
+
{
|
|
539
|
+
noNet: n[:no_net] || n[:noNet] || false,
|
|
540
|
+
ports: n[:ports] || [],
|
|
541
|
+
mac: n[:mac],
|
|
542
|
+
network: n[:network],
|
|
543
|
+
name: n[:name]
|
|
544
|
+
}
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def bsd_os_enum(os)
|
|
548
|
+
case os.to_s.downcase
|
|
549
|
+
when "freebsd" then "FREEBSD"
|
|
550
|
+
when "netbsd" then "NETBSD"
|
|
551
|
+
else raise ArgumentError, "unknown BSD os: #{os.inspect}"
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
# @param env [Hash, Array<String>, nil]
|
|
556
|
+
# @return [Array<String>] "K=V" pairs, as +openShell+'s +env:+ field wants.
|
|
557
|
+
def env_to_list(env)
|
|
558
|
+
return [] if env.nil?
|
|
559
|
+
return env if env.is_a?(Array)
|
|
560
|
+
|
|
561
|
+
env.map { |k, v| "#{k}=#{v}" }
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
end
|
data/lib/bsdkrun/errors.rb
CHANGED
|
@@ -49,4 +49,29 @@ module Bsdkrun
|
|
|
49
49
|
super("no sandbox found matching id #{id.inspect}")
|
|
50
50
|
end
|
|
51
51
|
end
|
|
52
|
+
|
|
53
|
+
# A GraphQL request to a remote +bsdkrund+ daemon failed — a transport
|
|
54
|
+
# failure, a non-JSON response, or a +body["errors"]+ entry that was not an
|
|
55
|
+
# auth failure. Mirrors +web/src/lib/graphql.ts+'s +GraphQLError+.
|
|
56
|
+
class GraphQLError < Error
|
|
57
|
+
# @return [String, nil] the error's +extensions.code+, when the daemon set one.
|
|
58
|
+
attr_reader :code
|
|
59
|
+
|
|
60
|
+
# @param message [String]
|
|
61
|
+
# @param code [String, nil]
|
|
62
|
+
def initialize(message, code = nil)
|
|
63
|
+
super(message)
|
|
64
|
+
@code = code
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The daemon rejected our token — an HTTP 401, a GraphQL error whose
|
|
69
|
+
# +extensions.code+ is +"UNAUTHENTICATED"+, or a websocket that closed
|
|
70
|
+
# before +connection_ack+ ever arrived.
|
|
71
|
+
class AuthError < GraphQLError
|
|
72
|
+
# @param message [String]
|
|
73
|
+
def initialize(message = "the daemon rejected this token")
|
|
74
|
+
super(message, "UNAUTHENTICATED")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
52
77
|
end
|