Class: Bsdkrun::Client
- Inherits:
-
Object
- Object
- Bsdkrun::Client
- Defined in:
- lib/bsdkrun/client.rb
Overview
A client that talks to a remote bsdkrund daemon's GraphQL API directly
— HTTP for queries/mutations, a hand-rolled graphql-transport-ws socket
for subscriptions — instead of shelling out to a local bsdkrun binary
the way Sandbox does.
The wire contract (URL/header shape, error mapping, subscription
protocol, field names) is locked to match the other bsdkrun SDKs
(TypeScript/Python/Elixir/Gleam) and the web frontend's
web/src/lib/graphql.ts byte-for-byte — see that file for the reference
implementation this one mirrors.
Constant Summary collapse
- URL_ENV =
"BSDKRUN_URL"- TOKEN_ENV =
"BSDKRUN_TOKEN"- MACHINE_FIELDS =
The
Machinefield selection shared bymachines/machine— mirrors +web/src/lib/api.ts+'sMACHINE_FIELDSfragment exactly. <<~GQL.freeze id name image kind command status running exitCode pid detached cpus mem volume stateDir createdAt finishedAt network netIp ports { bind host guest } GQL
Instance Attribute Summary collapse
-
#url ⇒ String
readonly
The GraphQL endpoint URL (normalized).
Class Method Summary collapse
-
.from_env ⇒ Client
Build a client from
BSDKRUN_URL/BSDKRUN_TOKEN. -
.normalize_url(input) ⇒ String
Normalize a user-supplied URL into a full GraphQL endpoint: trim, add
http://if no scheme was given, strip trailing slashes, append/graphqlunless the path already ends with it. -
.ws_url(http_url) ⇒ String
Derive the websocket endpoint from the HTTP one:
http://->ws://,https://->wss://, trailing slashes on the path stripped,/wsappended.
Instance Method Summary collapse
-
#commit(id, name, description: "") ⇒ CommandResult
Snapshot a machine into a named flavor, like
docker commit. -
#exec(id, command, env: nil) ⇒ ExecResult
Run a command to completion and collect its output.
-
#follow_logs(id, follow: true, boot: false) {|data| ... } ⇒ Proc
Stream a machine's console log live.
-
#get(id) ⇒ SandboxInfo?
Nil if no such machine exists.
-
#initialize(url:, token:) ⇒ Client
constructor
A new instance of Client.
- #list(all: false) ⇒ Array<SandboxInfo>
-
#logs(id, boot: false) ⇒ String
One-shot console log fetch.
- #remove(ids, force: false) ⇒ CommandResult
-
#request(query, variables = {}) ⇒ Hash
Run an arbitrary query or mutation.
-
#run_bsd(opts = {}, **kwargs) ⇒ String
The new machine's id.
-
#run_flavor(opts = {}, **kwargs) ⇒ String
The new machine's id.
-
#run_linux(opts = {}, **kwargs) ⇒ String
The new machine's id.
-
#run_nanos(opts = {}, **kwargs) ⇒ String
No agent (no exec/shell), but it does have a root disk, so
persist:is the one disk option it takes. -
#run_osv(opts = {}, **kwargs) ⇒ String
Like Nanos, no agent, but it does have a root filesystem, so the disk options apply —
disk:in particular, how an x86_64 guest gets a filesystem (its loader ELF is kernel only). -
#run_solo5(opts = {}, **kwargs) ⇒ String
Solo5 (MirageOS): runs under the
solo5-hvttender rather than libkrun. -
#run_unikraft(opts = {}, **kwargs) ⇒ String
A unikernel has no disk and no agent, so no volume/persist/repo/command fields —
mounts:(virtio-fs shares) is the exception, needing neither. -
#shell(id, command: nil, env: nil, rows: 24, cols: 80) ⇒ ShellSession
Open a live interactive session.
- #start(id) ⇒ CommandResult
- #stop(id) ⇒ CommandResult
-
#subscribe(query, variables = {}, on_next:, on_error: nil, on_complete: nil) ⇒ Proc
Start a subscription over the shared websocket (opened lazily on first use).
- #update(id, cpus: nil, mem: nil) ⇒ CommandResult
Constructor Details
#initialize(url:, token:) ⇒ Client
Returns a new instance of Client.
48 49 50 51 52 53 |
# File 'lib/bsdkrun/client.rb', line 48 def initialize(url:, token:) @url = self.class.normalize_url(url) @token = token.to_s @ws_mutex = Mutex.new @ws = nil end |
Instance Attribute Details
#url ⇒ String (readonly)
Returns the GraphQL endpoint URL (normalized).
43 44 45 |
# File 'lib/bsdkrun/client.rb', line 43 def url @url end |
Class Method Details
.from_env ⇒ Client
Build a client from BSDKRUN_URL / BSDKRUN_TOKEN.
A host set without a token is an error, not a silent fallback —
mirrors +daemon/src/client.rs+'s RemoteConfig::from_env (which uses
+BSDKRUN_HOST+/+BSDKRUN_TOKEN+ for the gRPC client; these are
GraphQL-specific env vars with a different URL shape, not aliases).
64 65 66 67 68 69 70 71 72 73 74 |
# File 'lib/bsdkrun/client.rb', line 64 def self.from_env url = ENV[URL_ENV] raise Error, "#{URL_ENV} is not set; nothing to connect to" if url.nil? || url.strip.empty? token = ENV[TOKEN_ENV] if token.nil? || token.strip.empty? raise Error, "#{URL_ENV} is set but #{TOKEN_ENV} is not" end new(url: url, token: token) end |
.normalize_url(input) ⇒ String
Normalize a user-supplied URL into a full GraphQL endpoint: trim, add
http:// if no scheme was given, strip trailing slashes, append
/graphql unless the path already ends with it. Mirrors
+web/src/lib/connection.ts+'s normalizeUrl exactly.
83 84 85 86 87 88 89 90 91 |
# File 'lib/bsdkrun/client.rb', line 83 def self.normalize_url(input) s = input.to_s.strip return s if s.empty? s = "http://#{s}" unless s.match?(%r{\Ahttps?://}i) s = s.sub(%r{/+\z}, "") s = "#{s}/graphql" unless s.match?(%r{/graphql\z}i) s end |
.ws_url(http_url) ⇒ String
Derive the websocket endpoint from the HTTP one: http:// -> ws://,
https:// -> wss://, trailing slashes on the path stripped, /ws
appended. Mirrors +web/src/lib/graphql.ts+'s wsUrl.
99 100 101 102 103 104 |
# File 'lib/bsdkrun/client.rb', line 99 def self.ws_url(http_url) uri = URI.parse(http_url) uri.scheme = uri.scheme == "https" ? "wss" : "ws" uri.path = "#{uri.path.to_s.sub(%r{/+\z}, "")}/ws" uri.to_s end |
Instance Method Details
#commit(id, name, description: "") ⇒ CommandResult
Snapshot a machine into a named flavor, like docker commit.
236 237 238 239 240 241 242 243 |
# File 'lib/bsdkrun/client.rb', line 236 def commit(id, name, description: "") run_command_mutation( "commitMachine", "mutation($id:String!,$name:String!,$description:String!){ " \ "commitMachine(id:$id, name:$name, description:$description){ exitCode stdout stderr } }", { id: id, name: name, description: description } ) end |
#exec(id, command, env: nil) ⇒ ExecResult
Run a command to completion and collect its output. Implemented as the
three-operation sequence daemon/README.md documents: openShell
(with a command:, so the session runs it instead of a login shell),
THEN subscribe to shellOutput (so nothing written in between is
lost), THEN wait for an exit code. closeShell runs in an ensure so
it happens whether the wait succeeded, failed, or raised.
432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 |
# File 'lib/bsdkrun/client.rb', line 432 def exec(id, command, env: nil) data = request( "mutation($m:String!,$c:[String!]!,$e:[String!]!,$r:Int!,$k:Int!){ " \ "openShell(machineId:$m, command:$c, env:$e, rows:$r, cols:$k){ id } }", { m: id, c: Array(command), e: env_to_list(env), r: 24, k: 80 } ) session_id = data["openShell"]["id"] output = +"".b exit_code = nil done = Queue.new unsubscribe = subscribe( "subscription($s:String!){ shellOutput(sessionId:$s){ dataBase64 exitCode } }", { s: session_id }, on_next: lambda { |d| payload = d && d["shellOutput"] next unless payload output << Base64.decode64(payload["dataBase64"]) if payload["dataBase64"] unless payload["exitCode"].nil? exit_code = payload["exitCode"] done << :done end }, on_error: ->(e) { done << e }, on_complete: -> { done << :done } ) begin result = done.pop raise result if result.is_a?(Exception) ensure unsubscribe.call begin request("mutation($s:String!){ closeShell(sessionId:$s) }", { s: session_id }) rescue GraphQLError # closeShell is idempotent server-side; a request failure here # (already gone, machine removed, etc.) must not mask the actual # exec result/exception above — same as the other SDKs' Client#exec. nil end end ExecResult.new(exit_code: exit_code, output: output) end |
#follow_logs(id, follow: true, boot: false) {|data| ... } ⇒ Proc
Stream a machine's console log live.
261 262 263 264 265 266 267 268 269 270 271 272 273 |
# File 'lib/bsdkrun/client.rb', line 261 def follow_logs(id, follow: true, boot: false, &on_data) subscribe( "subscription($id:String!,$follow:Boolean!,$boot:Boolean!){ " \ "machineLogs(id:$id, follow:$follow, boot:$boot){ dataBase64 exitCode } }", { id: id, follow: follow, boot: boot }, on_next: lambda { |data| payload = data && data["machineLogs"] next unless payload && payload["dataBase64"] on_data&.call(Base64.decode64(payload["dataBase64"])) } ) end |
#get(id) ⇒ SandboxInfo?
Returns nil if no such machine exists.
182 183 184 185 186 |
# File 'lib/bsdkrun/client.rb', line 182 def get(id) data = request("query($id:String!){ machine(id:$id){ #{MACHINE_FIELDS} } }", { id: id }) m = data["machine"] m && SandboxInfo.from_graphql(m) end |
#list(all: false) ⇒ Array<SandboxInfo>
175 176 177 178 |
# File 'lib/bsdkrun/client.rb', line 175 def list(all: false) data = request("query($all:Boolean!){ machines(all:$all){ #{MACHINE_FIELDS} } }", { all: all }) (data["machines"] || []).map { |m| SandboxInfo.from_graphql(m) } end |
#logs(id, boot: false) ⇒ String
One-shot console log fetch. Use #follow_logs to stream instead.
249 250 251 252 253 |
# File 'lib/bsdkrun/client.rb', line 249 def logs(id, boot: false) data = request("query($id:String!,$boot:Boolean!){ machineLogs(id:$id, boot:$boot) }", { id: id, boot: boot }) data["machineLogs"] end |
#remove(ids, force: false) ⇒ CommandResult
211 212 213 214 215 216 217 |
# File 'lib/bsdkrun/client.rb', line 211 def remove(ids, force: false) run_command_mutation( "removeMachines", "mutation($ids:[String!]!,$force:Boolean!){ removeMachines(ids:$ids, force:$force){ exitCode stdout stderr } }", { ids: Array(ids), force: force } ) end |
#request(query, variables = {}) ⇒ Hash
Run an arbitrary query or mutation. Every typed method on this class is implemented in terms of this — it exists as a public escape hatch for documents this SDK has no typed wrapper for yet.
119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 |
# File 'lib/bsdkrun/client.rb', line 119 def request(query, variables = {}) uri = URI.parse(@url) http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = uri.scheme == "https" req = Net::HTTP::Post.new(uri.request_uri.empty? ? "/" : uri.request_uri) req["content-type"] = "application/json" req["authorization"] = "Bearer #{@token}" req.body = JSON.generate({ query: query, variables: variables }) begin res = http.request(req) rescue StandardError => e raise GraphQLError, "cannot reach the bsdkrun daemon at #{@url} — #{e.}" end raise AuthError if res.code.to_i == 401 body = begin JSON.parse(res.body.to_s) rescue JSON::ParserError nil end raise GraphQLError, "the daemon returned a non-JSON response (#{res.code})" if body.nil? errors = body["errors"] if errors.is_a?(Array) && !errors.empty? first = errors.first = first["message"].to_s code = first.is_a?(Hash) ? first.dig("extensions", "code") : nil raise AuthError, if code == "UNAUTHENTICATED" raise GraphQLError.new(, code) end body["data"] end |
#run_bsd(opts = {}, **kwargs) ⇒ String
Returns the new machine's id.
309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 |
# File 'lib/bsdkrun/client.rb', line 309 def run_bsd(opts = {}, **kwargs) o = merge_opts(opts, kwargs) input = { os: bsd_os_enum(o.fetch(:os)), version: o[:version], cpus: o[:cpus], mem: o[:mem], net: net_input(o[:net]), volume: o[:volume], persist: o[:persist] || false, force: o[:force] || false, firmware: o[:firmware], attachDisk: o[:attach_disk] || [], diskSize: o[:disk_size], repo: o[:repo], command: o[:command] || [] } request("mutation($i:RunBsdInput!){ runBsd(input:$i) }", { i: input })["runBsd"] end |
#run_flavor(opts = {}, **kwargs) ⇒ String
Returns the new machine's id.
406 407 408 409 410 411 412 413 414 415 416 417 |
# File 'lib/bsdkrun/client.rb', line 406 def run_flavor(opts = {}, **kwargs) o = merge_opts(opts, kwargs) input = { name: o.fetch(:name), cpus: o[:cpus], mem: o[:mem], ports: o[:ports] || [], volume: o[:volume], repo: o[:repo] } request("mutation($i:RunFlavorInput!){ runFlavor(input:$i) }", { i: input })["runFlavor"] end |
#run_linux(opts = {}, **kwargs) ⇒ String
Returns the new machine's id.
287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 |
# File 'lib/bsdkrun/client.rb', line 287 def run_linux(opts = {}, **kwargs) o = merge_opts(opts, kwargs) input = { image: o.fetch(:image), cpus: o[:cpus], mem: o[:mem], net: net_input(o[:net]), volume: o[:volume], mounts: o[:mounts] || [], env: o[:env] || [], entrypoint: o[:entrypoint], initramfs: o[:initramfs] || false, kernel: o[:kernel], kernelVersion: o[:kernel_version], console: o[:console], repo: o[:repo], command: o[:command] || [] } request("mutation($i:RunLinuxInput!){ runLinux(input:$i) }", { i: input })["runLinux"] end |
#run_nanos(opts = {}, **kwargs) ⇒ String
No agent (no exec/shell), but it does have a root disk, so persist:
is the one disk option it takes.
332 333 334 335 336 337 338 339 340 341 342 343 344 |
# File 'lib/bsdkrun/client.rb', line 332 def run_nanos(opts = {}, **kwargs) o = merge_opts(opts, kwargs) input = { image: o.fetch(:image), cpus: o[:cpus], mem: o[:mem], net: net_input(o[:net]), kernel: o[:kernel], cmdline: o[:cmdline], persist: o[:persist] || false } request("mutation($i:RunNanosInput!){ runNanos(input:$i) }", { i: input })["runNanos"] end |
#run_osv(opts = {}, **kwargs) ⇒ String
Like Nanos, no agent, but it does have a root filesystem, so the disk
options apply — disk: in particular, how an x86_64 guest gets a
filesystem (its loader ELF is kernel only).
387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 |
# File 'lib/bsdkrun/client.rb', line 387 def run_osv(opts = {}, **kwargs) o = merge_opts(opts, kwargs) input = { image: o.fetch(:image), cpus: o[:cpus], mem: o[:mem], net: net_input(o[:net]), cmdline: o[:cmdline], disk: o[:disk], noDisk: o[:no_disk] || false, attachDisk: o[:attach_disk] || [], gic: o[:gic], persist: o[:persist] || false, volume: o[:volume] } request("mutation($i:RunOsvInput!){ runOsv(input:$i) }", { i: input })["runOsv"] end |
#run_solo5(opts = {}, **kwargs) ⇒ String
Solo5 (MirageOS): runs under the solo5-hvt tender rather than libkrun.
The unikernel declares its own network and block devices in its MFT1
manifest note, so only what the host alone can know is asked for —
block: backing files (+NAME=FILE+) and the unikernel's own args:
(e.g. "--ipv4=10.0.0.2/24"). Like Unikraft, no disk and no agent, so no
volume/persist/repo/command fields.
370 371 372 373 374 375 376 377 378 379 380 381 |
# File 'lib/bsdkrun/client.rb', line 370 def run_solo5(opts = {}, **kwargs) o = merge_opts(opts, kwargs) input = { path: o[:path], cpus: o[:cpus], mem: o[:mem], net: net_input(o[:net]), block: o[:block] || [], args: o[:args] || [] } request("mutation($i:RunSolo5Input!){ runSolo5(input:$i) }", { i: input })["runSolo5"] end |
#run_unikraft(opts = {}, **kwargs) ⇒ String
A unikernel has no disk and no agent, so no volume/persist/repo/command
fields — mounts: (virtio-fs shares) is the exception, needing neither.
349 350 351 352 353 354 355 356 357 358 359 360 361 |
# File 'lib/bsdkrun/client.rb', line 349 def run_unikraft(opts = {}, **kwargs) o = merge_opts(opts, kwargs) input = { path: o[:path], cpus: o[:cpus], mem: o[:mem], net: net_input(o[:net]), cmdline: o[:cmdline], initramfs: o[:initramfs], mounts: o[:mounts] || [] } request("mutation($i:RunUnikraftInput!){ runUnikraft(input:$i) }", { i: input })["runUnikraft"] end |
#shell(id, command: nil, env: nil, rows: 24, cols: 80) ⇒ ShellSession
Open a live interactive session. Unlike #exec, this returns immediately with a handle whose ShellSession#on_output / ShellSession#on_exit callbacks fire as output arrives.
489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 |
# File 'lib/bsdkrun/client.rb', line 489 def shell(id, command: nil, env: nil, rows: 24, cols: 80) data = request( "mutation($m:String!,$c:[String!]!,$e:[String!]!,$r:Int!,$k:Int!){ " \ "openShell(machineId:$m, command:$c, env:$e, rows:$r, cols:$k){ id } }", { m: id, c: command.nil? ? [] : Array(command), e: env_to_list(env), r: rows, k: cols } ) session_id = data["openShell"]["id"] session = ShellSession.new(client: self, id: session_id) unsubscribe = subscribe( "subscription($s:String!){ shellOutput(sessionId:$s){ dataBase64 exitCode } }", { s: session_id }, on_next: lambda { |d| payload = d && d["shellOutput"] next unless payload session.deliver_output(Base64.decode64(payload["dataBase64"])) if payload["dataBase64"] session.deliver_exit(payload["exitCode"]) unless payload["exitCode"].nil? }, on_error: ->(_e) { session.deliver_exit(nil) }, on_complete: -> {} ) session.unsubscribe = unsubscribe session end |
#start(id) ⇒ CommandResult
200 201 202 203 204 205 206 |
# File 'lib/bsdkrun/client.rb', line 200 def start(id) run_command_mutation( "startMachine", "mutation($id:String!){ startMachine(id:$id){ exitCode stdout stderr } }", { id: id } ) end |
#stop(id) ⇒ CommandResult
190 191 192 193 194 195 196 |
# File 'lib/bsdkrun/client.rb', line 190 def stop(id) run_command_mutation( "stopMachine", "mutation($id:String!){ stopMachine(id:$id){ exitCode stdout stderr } }", { id: id } ) end |
#subscribe(query, variables = {}, on_next:, on_error: nil, on_complete: nil) ⇒ Proc
Start a subscription over the shared websocket (opened lazily on first use). See WsClient#subscribe for the exact queueing/ack semantics.
167 168 169 |
# File 'lib/bsdkrun/client.rb', line 167 def subscribe(query, variables = {}, on_next:, on_error: nil, on_complete: nil) ws.subscribe(query, variables, on_next: on_next, on_error: on_error, on_complete: on_complete) end |
#update(id, cpus: nil, mem: nil) ⇒ CommandResult
223 224 225 226 227 228 229 |
# File 'lib/bsdkrun/client.rb', line 223 def update(id, cpus: nil, mem: nil) run_command_mutation( "updateMachine", "mutation($id:String!,$cpus:Int,$mem:Int){ updateMachine(id:$id, cpus:$cpus, mem:$mem){ exitCode stdout stderr } }", { id: id, cpus: cpus, mem: mem } ) end |