sandbox-provider 0.1.1 → 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: 012300e69a3b0389101f1d843340008aab5e7cc0ef0173227187e7a0f889ec52
4
- data.tar.gz: 10e39f638d1aec981f651a0ed391a07834fbb0a900944ae8ddcff6996a726cea
3
+ metadata.gz: 9e0e09727229563480ccb1c17fa80d6006fc308373d485a5e74e6b5dbfeeaa95
4
+ data.tar.gz: eb2b8ab9b4e9c83dd2b1def46e15efbee8c3e659497e52b2a6b3b37d38ca35cc
5
5
  SHA512:
6
- metadata.gz: 12d574beb0f49b05519c9ff95fae5de37d5c54baf0fa034b1d0d64d138e99d6925531ea48d449e02d7ca4adbfe4a7d64597067a750e73b57a358de96388d66e5
7
- data.tar.gz: 7370726a28469cf76d5d55d55a20fc226928bea484e9739be9cba51f6e88bc32ab0b75b4787f6ce421f8486bd2ee22d27fcd1c354e78a434885c7c5436286f4d
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,13 +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 (root or the deploy user) shares it. A fork gets its own.
19
- TOKENS = {}
20
- TOKENS_LOCK = Mutex.new
21
15
 
22
16
  def initialize(token, client: Client.new(token), **)
23
17
  super(token, **)
@@ -150,7 +144,6 @@ to: Time.current.utc.iso8601 })
150
144
 
151
145
  # A parent with live forks cannot go: the error says so.
152
146
  def destroy(m)
153
- TOKENS_LOCK.synchronize { TOKENS.delete(m.id) }
154
147
  @client.delete("/sandbox/#{m.id}", force: true)
155
148
  rescue Error => e
156
149
  raise unless e.status == 404
@@ -164,20 +157,10 @@ to: Time.current.utc.iso8601 })
164
157
  wake(machine(sandbox))
165
158
  end
166
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.
167
162
  def transport(m, key: nil, host_key: nil, on_host_key: nil)
168
- access = TOKENS_LOCK.synchronize do
169
- kept = TOKENS[m.id]
170
- kept = nil if kept.nil? || kept.until < Time.current
171
- kept || (TOKENS[m.id] = mint(m.id))
172
- end
173
- Sandbox::Adapter::Ssh.new(
174
- host: access.host,
175
- port: access.port,
176
- user: access.token,
177
- key: nil,
178
- host_key:,
179
- on_host_key:
180
- )
163
+ Toolbox.new(m.id, @token, base: m.data[:toolboxProxyUrl])
181
164
  end
182
165
 
183
166
  # Their preview link for the port: a host at their edge and a token it wants in a header. The token rotates
@@ -234,21 +217,6 @@ to: Time.current.utc.iso8601 })
234
217
  { cpu: 16, memory: 32, disk: 15 }
235
218
  end
236
219
 
237
- def mint(sandbox_id)
238
- access = @client.post(
239
- "/sandbox/#{sandbox_id}/ssh-access",
240
- {},
241
- params: { expiresInMinutes: TOKEN_MINUTES }
242
- )
243
- command = access[:sshCommand].to_s
244
- Access.new(
245
- host: command[/@(\S+)/, 1],
246
- port: (command[/-p (\d+)/, 1] || 22).to_i,
247
- token: access[:token],
248
- until: Time.current + (TOKEN_MINUTES - 10).minutes
249
- )
250
- end
251
-
252
220
  def wait(id, timeout: 300)
253
221
  deadline = Time.current + timeout
254
222
  loop do
@@ -1,5 +1,5 @@
1
1
  module Sandbox
2
2
  module Provider
3
- VERSION = "0.1.1"
3
+ VERSION = "0.1.3"
4
4
  end
5
5
  end
@@ -12,6 +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/toolbox"
15
16
  require_relative "sandbox/provider/e2b"
16
17
  require_relative "sandbox/provider/e2b/client"
17
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.1
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,6 +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/toolbox.rb
55
56
  - lib/sandbox/provider/e2b.rb
56
57
  - lib/sandbox/provider/e2b/client.rb
57
58
  - lib/sandbox/provider/e2b/envd.rb