sandbox-provider 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c4be6ee4a326cae728dfa5986e371fb23a3b51a34646c3fc26d0c79e95647a7b
4
- data.tar.gz: fd8ec5fda4686750696ea0e7ea14bdc317eeef2a90b6156ccee3c5ee91595e3b
3
+ metadata.gz: 9e0e09727229563480ccb1c17fa80d6006fc308373d485a5e74e6b5dbfeeaa95
4
+ data.tar.gz: eb2b8ab9b4e9c83dd2b1def46e15efbee8c3e659497e52b2a6b3b37d38ca35cc
5
5
  SHA512:
6
- metadata.gz: f874cc33545840d4a68b082858285aa9824f61743db170c38fa82a7b208ed646028be1750069f4a77a38707c306d54ebd52c191fd6b0f9a79d8f7c01beb9bbc5
7
- data.tar.gz: 8b6d3e75a0e01167d9a83950b0d481abbf2267e3491283cdb82f84137594d441b971c1dab46c7182addd9c8a1fa87fc2482ddc95749f5c0e90e03e10d460fdde
6
+ metadata.gz: e9db2ef6f5b3ec73c6a9f81f50a825425a06ff07c9d890ab3675dc01efcd32f07289165be8bb78f1df42406004da737f355396bd6463e263c738d97312a569ef
7
+ data.tar.gz: d97de7d218e2118880fe0bb578ddaace7d5b58ad498f6f743a6b7c11aff91c30f16d3e54215c47987037a627e9c3d4d46f19fd3d37408c58bc396978e3bd9a0e
@@ -0,0 +1,128 @@
1
+ require "net/http"
2
+ require "securerandom"
3
+
4
+ # Reaches a Daytona sandbox through its toolbox, the daemon Daytona runs in every sandbox, over HTTP with the
5
+ # organization's API key: nothing to mint, nothing another process can revoke. Answers the verbs Box asks of a
6
+ # transport, as Sandbox::Adapter::Ssh does.
7
+ #
8
+ # A command runs in a process session of its own, as root, started async; its log is read every POLL seconds
9
+ # and only the bytes not yet read are yielded, until the command has an exit code. The session is deleted
10
+ # afterwards, which ends anything the command left running. A command is always a subprocess of the session's
11
+ # shell (Box sends `bash -lc`), so its `exit` ends the command, not the session.
12
+ class Sandbox::Provider::Daytona::Toolbox
13
+ # The sandbox cannot be reached for now: stopped, restarting, the proxy or the network failing. Box retries
14
+ # it within its deadline, as it does a dropped SSH link.
15
+ Unreachable = Class.new(StandardError)
16
+
17
+ BASE = "https://proxy.app.daytona.io/toolbox".freeze
18
+ POLL = 0.5
19
+ # Seconds a command without `within` may run before it is given up on: a command whose exit never comes
20
+ # back (a toolbox that lost it) must not hold its caller for good. Longer than anything Box runs plainly.
21
+ LIMIT = 2 * 3600
22
+ # Answers that mean the sandbox is not there to answer yet, as opposed to refusing the request.
23
+ AGAIN = [ 408, 425, 429, 500, 502, 503, 504 ].freeze
24
+
25
+ attr_reader :sandbox_id
26
+
27
+ def initialize(sandbox_id, token, base: BASE)
28
+ @sandbox_id = sandbox_id
29
+ @token = token
30
+ @base = base.presence || BASE
31
+ end
32
+
33
+ def address = sandbox_id
34
+ def connection_errors = [ Unreachable ]
35
+ # A detached command's log can be followed again after a dropped request.
36
+ def reattaches? = true
37
+ # Takes `within:` on exec.
38
+ def bounded? = true
39
+
40
+ # Runs a shell command line; each output line is yielded; returns the exit status. With `within`, a command
41
+ # still running after that many seconds raises Unreachable, which its caller retries.
42
+ def exec(command, within: nil, &)
43
+ session = "nvoi-#{SecureRandom.hex(8)}"
44
+ request(Net::HTTP::Post, "/process/session", json: { sessionId: session })
45
+ begin
46
+ started = request(Net::HTTP::Post, "/process/session/#{session}/exec",
47
+ json: { command:, runAsync: true })
48
+ follow(session, JSON.parse(started).fetch("cmdId"), within:, &)
49
+ ensure
50
+ close(session)
51
+ end
52
+ end
53
+
54
+ def upload(path, io)
55
+ body = io.respond_to?(:read) ? io.read : File.binread(io)
56
+ boundary = "nvoi-#{SecureRandom.hex(12)}"
57
+ name = File.basename(path)
58
+ head = "--#{boundary}\r\n" \
59
+ "Content-Disposition: form-data; name=\"file\"; filename=\"#{name}\"\r\n" \
60
+ "Content-Type: application/octet-stream\r\n\r\n"
61
+ form = head.b + body.to_s.b + "\r\n--#{boundary}--\r\n".b
62
+ request(Net::HTTP::Post, "/files/upload", params: { path: }, body: form,
63
+ type: "multipart/form-data; boundary=#{boundary}")
64
+ end
65
+
66
+ def download(path) = request(Net::HTTP::Get, "/files/download", params: { path: })
67
+
68
+ private
69
+
70
+ # New log bytes until the command has an exit code, then whatever it printed last.
71
+ def follow(session, command, within:)
72
+ deadline = Time.current + (within || LIMIT)
73
+ read = 0
74
+ buffer = "".b
75
+ path = "/process/session/#{session}/command/#{command}"
76
+ loop do
77
+ status = JSON.parse(request(Net::HTTP::Get, path)).fetch("exitCode", nil)
78
+ log = request(Net::HTTP::Get, "#{path}/logs").b
79
+ Sandbox::Adapter::Box.each_line(buffer, log.byteslice(read..).to_s) { |line| yield line if block_given? }
80
+ read = log.bytesize
81
+ unless status.nil?
82
+ yield buffer.force_encoding("UTF-8").scrub if buffer.present? && block_given?
83
+ return status
84
+ end
85
+ if Time.current > deadline
86
+ raise Unreachable, "#{sandbox_id}: command still running after #{within}s" if within
87
+
88
+ raise Sandbox::Adapter::Box::Failed, "#{sandbox_id}: no exit status after #{LIMIT}s"
89
+ end
90
+ sleep POLL
91
+ end
92
+ end
93
+
94
+ # A session that cannot be deleted now ends with the sandbox; its command is not ours to wait on.
95
+ def close(session)
96
+ request(Net::HTTP::Delete, "/process/session/#{session}")
97
+ rescue Unreachable, Sandbox::Adapter::Box::Failed
98
+ nil
99
+ end
100
+
101
+ def request(klass, path, params: {}, json: nil, body: nil, type: nil)
102
+ uri = URI("#{@base}/#{sandbox_id}#{path}")
103
+ uri.query = URI.encode_www_form(params) if params.any?
104
+ req = klass.new(uri)
105
+ req["Authorization"] = "Bearer #{@token}"
106
+ if json
107
+ req["Content-Type"] = "application/json"
108
+ req.body = json.to_json
109
+ elsif body
110
+ req["Content-Type"] = type
111
+ req.body = body
112
+ end
113
+ res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10,
114
+ read_timeout: 60) { _1.request(req) }
115
+ answer(res, klass, path)
116
+ rescue *Sandbox::Adapter::Provider::UNREACHABLE => e
117
+ raise Unreachable, "#{sandbox_id}: #{e.class}: #{e.message}"
118
+ end
119
+
120
+ def answer(res, klass, path)
121
+ return res.body.to_s if res.is_a?(Net::HTTPSuccess)
122
+
123
+ said = "#{klass::METHOD} #{path} #{res.code}: #{res.body.to_s.strip[0, 200]}"
124
+ raise Unreachable, "#{sandbox_id}: #{said}" if AGAIN.include?(res.code.to_i)
125
+
126
+ raise Sandbox::Adapter::Box::Failed, "daytona toolbox #{said}"
127
+ end
128
+ end
@@ -1,4 +1,5 @@
1
- # Daytona linux-vm sandboxes: one VM snapshot per shape and region. SSH lands as root behind a short-lived token.
1
+ # Daytona linux-vm sandboxes: one VM snapshot per shape and region. Commands and files go through the
2
+ # sandbox's toolbox with the API key (Daytona::Toolbox), as root; there is no SSH.
2
3
  class Sandbox::Provider::Daytona < Sandbox::Adapter::SandboxVm
3
4
  IMAGE = "ubuntu:24.04"
4
5
  REGION = "uk-south-1"
@@ -11,29 +12,6 @@ class Sandbox::Provider::Daytona < Sandbox::Adapter::SandboxVm
11
12
  "build_failed" => :broken,
12
13
  "destroyed" => :broken
13
14
  }.freeze
14
- TOKEN_MINUTES = 24 * 60
15
- # An SSH token and where it lands, good until `until`.
16
- Access = Data.define(:host, :port, :token, :until)
17
- # One token per sandbox, kept until it nears expiry: minting a new one revokes the previous, so every
18
- # transport to the same sandbox shares it, across processes when the store is shared. A fork gets its own.
19
- # A store answers read(id), write(id, access), delete(id) and synchronize(id) { }.
20
- class Tokens
21
- def initialize
22
- @tokens = {}
23
- @lock = Monitor.new
24
- end
25
-
26
- def read(id) = @tokens[id]
27
- def write(id, access) = @tokens[id] = access
28
- def delete(id) = @tokens.delete(id)
29
- def synchronize(_id, &) = @lock.synchronize(&)
30
- end
31
-
32
- class << self
33
- attr_writer :tokens
34
-
35
- def tokens = @tokens ||= Tokens.new
36
- end
37
15
 
38
16
  def initialize(token, client: Client.new(token), **)
39
17
  super(token, **)
@@ -166,7 +144,6 @@ to: Time.current.utc.iso8601 })
166
144
 
167
145
  # A parent with live forks cannot go: the error says so.
168
146
  def destroy(m)
169
- self.class.tokens.synchronize(m.id) { self.class.tokens.delete(m.id) }
170
147
  @client.delete("/sandbox/#{m.id}", force: true)
171
148
  rescue Error => e
172
149
  raise unless e.status == 404
@@ -180,19 +157,10 @@ to: Time.current.utc.iso8601 })
180
157
  wake(machine(sandbox))
181
158
  end
182
159
 
160
+ # The sandbox's toolbox, reached with the API key: the same in every process, and nothing to revoke. A
161
+ # key or a host key has no use here.
183
162
  def transport(m, key: nil, host_key: nil, on_host_key: nil)
184
- Link.new(-> (stale) { access(m.id, stale:) }, host_key:, on_host_key:)
185
- end
186
-
187
- # The sandbox's token from the store, minted when there is none, it nears expiry, or it is `stale`: the
188
- # one a refused connection used. Another process may have replaced it already; then that one is kept.
189
- def access(id, stale: nil)
190
- tokens = self.class.tokens
191
- tokens.synchronize(id) do
192
- kept = tokens.read(id)
193
- kept = nil if kept.nil? || kept.until < Time.current || kept.token == stale
194
- kept || mint(id).tap { |minted| tokens.write(id, minted) }
195
- end
163
+ Toolbox.new(m.id, @token, base: m.data[:toolboxProxyUrl])
196
164
  end
197
165
 
198
166
  # Their preview link for the port: a host at their edge and a token it wants in a header. The token rotates
@@ -249,21 +217,6 @@ to: Time.current.utc.iso8601 })
249
217
  { cpu: 16, memory: 32, disk: 15 }
250
218
  end
251
219
 
252
- def mint(sandbox_id)
253
- access = @client.post(
254
- "/sandbox/#{sandbox_id}/ssh-access",
255
- {},
256
- params: { expiresInMinutes: TOKEN_MINUTES }
257
- )
258
- command = access[:sshCommand].to_s
259
- Access.new(
260
- host: command[/@(\S+)/, 1],
261
- port: (command[/-p (\d+)/, 1] || 22).to_i,
262
- token: access[:token],
263
- until: Time.current + (TOKEN_MINUTES - 10).minutes
264
- )
265
- end
266
-
267
220
  def wait(id, timeout: 300)
268
221
  deadline = Time.current + timeout
269
222
  loop do
@@ -1,5 +1,5 @@
1
1
  module Sandbox
2
2
  module Provider
3
- VERSION = "0.1.2"
3
+ VERSION = "0.1.3"
4
4
  end
5
5
  end
@@ -12,7 +12,7 @@ require_relative "sandbox/provider/hetzner"
12
12
  require_relative "sandbox/provider/hetzner/client"
13
13
  require_relative "sandbox/provider/daytona"
14
14
  require_relative "sandbox/provider/daytona/client"
15
- require_relative "sandbox/provider/daytona/link"
15
+ require_relative "sandbox/provider/daytona/toolbox"
16
16
  require_relative "sandbox/provider/e2b"
17
17
  require_relative "sandbox/provider/e2b/client"
18
18
  require_relative "sandbox/provider/e2b/envd"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sandbox-provider
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - nvoi
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: 0.1.1
18
+ version: 0.1.2
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: 0.1.1
25
+ version: 0.1.2
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: ed25519
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -52,7 +52,7 @@ files:
52
52
  - lib/sandbox/provider/cloud_init.rb
53
53
  - lib/sandbox/provider/daytona.rb
54
54
  - lib/sandbox/provider/daytona/client.rb
55
- - lib/sandbox/provider/daytona/link.rb
55
+ - lib/sandbox/provider/daytona/toolbox.rb
56
56
  - lib/sandbox/provider/e2b.rb
57
57
  - lib/sandbox/provider/e2b/client.rb
58
58
  - lib/sandbox/provider/e2b/envd.rb
@@ -1,59 +0,0 @@
1
- # An SSH transport whose token another process can revoke by minting a new one. A command refused before
2
- # it printed anything is tried once more with the store's current token.
3
- class Sandbox::Provider::Daytona::Link
4
- RETRY = [ *Sandbox::Adapter::Ssh::CONNECTION_ERRORS, Net::SSH::AuthenticationFailed ].freeze
5
-
6
- def initialize(access, host_key: nil, on_host_key: nil)
7
- @access = access
8
- @host_key = host_key
9
- @on_host_key = on_host_key
10
- @ssh = build(access.call(nil))
11
- end
12
-
13
- def address = @ssh.address
14
- def host = @ssh.host
15
- def port = @ssh.port
16
- def user = @ssh.user
17
- def key = @ssh.key
18
- def connection_errors = Sandbox::Adapter::Ssh::CONNECTION_ERRORS
19
- def reattaches? = true
20
-
21
- def exec(command, &block)
22
- printed = false
23
- begin
24
- @ssh.exec(command) do |line|
25
- printed = true
26
- block&.call(line)
27
- end
28
- rescue *RETRY
29
- raise if printed
30
-
31
- refresh
32
- @ssh.exec(command, &block)
33
- end
34
- end
35
-
36
- def upload(path, io)
37
- @ssh.upload(path, io)
38
- rescue *RETRY
39
- io.rewind if io.respond_to?(:rewind)
40
- refresh
41
- @ssh.upload(path, io)
42
- end
43
-
44
- def download(path)
45
- @ssh.download(path)
46
- rescue *RETRY
47
- refresh
48
- @ssh.download(path)
49
- end
50
-
51
- private
52
-
53
- def refresh = @ssh = build(@access.call(@ssh.user))
54
-
55
- def build(access)
56
- Sandbox::Adapter::Ssh.new(host: access.host, port: access.port, user: access.token, key: nil,
57
- host_key: @host_key, on_host_key: ->(key) { @host_key = key; @on_host_key&.call(key) })
58
- end
59
- end