Class: Bsdkrun::Client

Inherits:
Object
  • Object
show all
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.

Examples:

client = Bsdkrun::Client.from_env
client.list.each { |m| puts m.id }
result = client.exec(id, ["uname", "-a"])
puts result.output

Constant Summary collapse

URL_ENV =
"BSDKRUN_URL"
TOKEN_ENV =
"BSDKRUN_TOKEN"
MACHINE_FIELDS =

The Machine field selection shared by machines / machine — mirrors +web/src/lib/api.ts+'s MACHINE_FIELDS fragment 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

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(url:, token:) ⇒ Client

Returns a new instance of Client.

Parameters:



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

#urlString (readonly)

Returns the GraphQL endpoint URL (normalized).

Returns:

  • (String)

    the GraphQL endpoint URL (normalized).



43
44
45
# File 'lib/bsdkrun/client.rb', line 43

def url
  @url
end

Class Method Details

.from_envClient

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).

Returns:

Raises:

  • (Error)

    if BSDKRUN_URL is unset, or set without BSDKRUN_TOKEN.



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.

Parameters:

  • input (String)

Returns:

  • (String)


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.

Parameters:

  • http_url (String)

Returns:

  • (String)


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.

Parameters:

  • id (String)
  • name (String)
  • description (String) (defaults to: "")

Returns:



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.

Parameters:

  • id (String)

    machine id.

  • command (Array<String>)

    argv.

  • env (Hash, Array<String>, nil) (defaults to: nil)

    "K=V" pairs, or a Hash of them.

Returns:



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.

Parameters:

  • id (String)
  • follow (Boolean) (defaults to: true)
  • boot (Boolean) (defaults to: false)

Yield Parameters:

  • data (String)

    binary-safe decoded chunk.

Returns:

  • (Proc)

    call to stop following.



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.

Parameters:

  • id (String)

    id, name, or unique id prefix.

Returns:

  • (SandboxInfo, nil)

    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>

Parameters:

  • all (Boolean) (defaults to: false)

    include stopped machines too.

Returns:



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.

Parameters:

  • id (String)
  • boot (Boolean) (defaults to: false)

    bsdkrun's own boot log instead of the guest console.

Returns:

  • (String)


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

Parameters:

  • ids (String, Array<String>)
  • force (Boolean) (defaults to: false)

Returns:



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.

Parameters:

  • query (String)

    a GraphQL document.

  • variables (Hash) (defaults to: {})

Returns:

  • (Hash)

    body["data"] (String-keyed, as parsed by JSON.parse).

Raises:

  • (AuthError)

    on HTTP 401, or a GraphQL error with extensions.code == "UNAUTHENTICATED".

  • (GraphQLError)

    on transport failure, a non-JSON response, or any other GraphQL error.



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.message}"
  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
    message = first["message"].to_s
    code = first.is_a?(Hash) ? first.dig("extensions", "code") : nil
    raise AuthError, message if code == "UNAUTHENTICATED"
    raise GraphQLError.new(message, code)
  end

  body["data"]
end

#run_bsd(opts = {}, **kwargs) ⇒ String

Returns the new machine's id.

Returns:

  • (String)

    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.

Returns:

  • (String)

    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.

Returns:

  • (String)

    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.

Returns:

  • (String)

    the new machine's id.



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).

Returns:

  • (String)

    the new machine's id.



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.

Returns:

  • (String)

    the new machine's id.



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.

Returns:

  • (String)

    the new machine's id.



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.

Parameters:

  • id (String)

    machine id.

  • command (Array<String>, nil) (defaults to: nil)

    nil opens a login shell.

  • env (Hash, Array<String>, nil) (defaults to: nil)
  • rows (Integer) (defaults to: 24)
  • cols (Integer) (defaults to: 80)

Returns:



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

Parameters:

  • id (String)

Returns:



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

Parameters:

  • id (String)

Returns:



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.

Parameters:

  • query (String)
  • variables (Hash) (defaults to: {})
  • on_next (#call)
  • on_error (#call, nil) (defaults to: nil)
  • on_complete (#call, nil) (defaults to: nil)

Returns:

  • (Proc)

    call to unsubscribe.



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

Parameters:

  • id (String)
  • cpus (Integer, nil) (defaults to: nil)
  • mem (Integer, nil) (defaults to: nil)

Returns:



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