e2b 0.2.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 +6 -2
- data/lib/e2b/api/http_client.rb +30 -19
- data/lib/e2b/client.rb +79 -36
- data/lib/e2b/configuration.rb +12 -6
- data/lib/e2b/dockerfile_parser.rb +179 -0
- data/lib/e2b/errors.rb +24 -1
- data/lib/e2b/models/build_info.rb +29 -0
- data/lib/e2b/models/build_status_reason.rb +27 -0
- data/lib/e2b/models/sandbox_info.rb +19 -2
- data/lib/e2b/models/snapshot_info.rb +19 -0
- data/lib/e2b/models/template_build_status_response.rb +31 -0
- data/lib/e2b/models/template_log_entry.rb +54 -0
- data/lib/e2b/models/template_tag.rb +34 -0
- data/lib/e2b/models/template_tag_info.rb +21 -0
- data/lib/e2b/paginator.rb +97 -0
- data/lib/e2b/ready_cmd.rb +36 -0
- data/lib/e2b/sandbox.rb +217 -66
- data/lib/e2b/sandbox_helpers.rb +100 -0
- data/lib/e2b/services/base_service.rb +64 -15
- data/lib/e2b/services/command_handle.rb +189 -36
- data/lib/e2b/services/commands.rb +37 -50
- data/lib/e2b/services/filesystem.rb +70 -23
- data/lib/e2b/services/live_streamable.rb +94 -0
- data/lib/e2b/services/pty.rb +13 -64
- data/lib/e2b/services/watch_handle.rb +6 -3
- data/lib/e2b/template.rb +1089 -0
- data/lib/e2b/template_logger.rb +52 -0
- data/lib/e2b/version.rb +1 -1
- data/lib/e2b.rb +16 -0
- metadata +44 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 103831a7280b1f523e4a7299820e40b328d0fd840f65850f42336f8c8b0e23ee
|
|
4
|
+
data.tar.gz: d079861b11c20bc1e2280badc7e7e9032810a736a7bbca02a433802be27b2053
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9fa1e68c98160a18f31afb994e66fc55d20647cc0769f80afbc8ab6fccc45df2c2e7d0f0033a37dd3e7a1571d373b0cd92060f98f6ab675241aa61309e0e70d9
|
|
7
|
+
data.tar.gz: 759cffe0f051858f7f851f2f7599d495b24ca6abad1d313bed0cc96a7794e2a47eecacfdd6ecc81577d6fba18c9094824ec235022b8746047d630dce697ace0c
|
data/README.md
CHANGED
|
@@ -17,10 +17,14 @@ Aligned with the [official E2B SDKs](https://github.com/e2b-dev/E2B) (Python/JS)
|
|
|
17
17
|
|
|
18
18
|
## Installation
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
```bash
|
|
21
|
+
gem install e2b
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or add to your Gemfile:
|
|
21
25
|
|
|
22
26
|
```ruby
|
|
23
|
-
gem 'e2b'
|
|
27
|
+
gem 'e2b'
|
|
24
28
|
```
|
|
25
29
|
|
|
26
30
|
Then:
|
data/lib/e2b/api/http_client.rb
CHANGED
|
@@ -10,6 +10,8 @@ module E2B
|
|
|
10
10
|
#
|
|
11
11
|
# Handles authentication, request/response processing, and error handling.
|
|
12
12
|
class HttpClient
|
|
13
|
+
DetailedResponse = Struct.new(:body, :headers, keyword_init: true)
|
|
14
|
+
|
|
13
15
|
# Default request timeout in seconds
|
|
14
16
|
DEFAULT_TIMEOUT = 120
|
|
15
17
|
|
|
@@ -19,11 +21,13 @@ module E2B
|
|
|
19
21
|
# Initialize a new HTTP client
|
|
20
22
|
#
|
|
21
23
|
# @param base_url [String] Base URL for API requests
|
|
22
|
-
# @param api_key [String] API key for authentication
|
|
24
|
+
# @param api_key [String, nil] API key for authentication
|
|
25
|
+
# @param access_token [String, nil] Access token for bearer authentication
|
|
23
26
|
# @param logger [Logger, nil] Optional logger
|
|
24
|
-
def initialize(base_url:, api_key
|
|
27
|
+
def initialize(base_url:, api_key: nil, access_token: nil, logger: nil)
|
|
25
28
|
@base_url = base_url.end_with?("/") ? base_url : "#{base_url}/"
|
|
26
29
|
@api_key = api_key
|
|
30
|
+
@access_token = access_token
|
|
27
31
|
@logger = logger
|
|
28
32
|
@connection = build_connection
|
|
29
33
|
end
|
|
@@ -34,8 +38,8 @@ module E2B
|
|
|
34
38
|
# @param params [Hash] Query parameters
|
|
35
39
|
# @param timeout [Integer] Request timeout in seconds
|
|
36
40
|
# @return [Hash, Array, String] Parsed response body
|
|
37
|
-
def get(path, params: {}, timeout: DEFAULT_TIMEOUT)
|
|
38
|
-
handle_response do
|
|
41
|
+
def get(path, params: {}, timeout: DEFAULT_TIMEOUT, detailed: false)
|
|
42
|
+
handle_response(detailed: detailed) do
|
|
39
43
|
@connection.get(normalize_path(path)) do |req|
|
|
40
44
|
req.params = params
|
|
41
45
|
req.options.timeout = timeout
|
|
@@ -49,8 +53,8 @@ module E2B
|
|
|
49
53
|
# @param body [Hash, nil] Request body
|
|
50
54
|
# @param timeout [Integer] Request timeout in seconds
|
|
51
55
|
# @return [Hash, Array, String] Parsed response body
|
|
52
|
-
def post(path, body: nil, timeout: DEFAULT_TIMEOUT)
|
|
53
|
-
handle_response do
|
|
56
|
+
def post(path, body: nil, timeout: DEFAULT_TIMEOUT, detailed: false)
|
|
57
|
+
handle_response(detailed: detailed) do
|
|
54
58
|
@connection.post(normalize_path(path)) do |req|
|
|
55
59
|
req.body = body.to_json if body
|
|
56
60
|
req.options.timeout = timeout
|
|
@@ -64,8 +68,8 @@ module E2B
|
|
|
64
68
|
# @param body [Hash, nil] Request body
|
|
65
69
|
# @param timeout [Integer] Request timeout in seconds
|
|
66
70
|
# @return [Hash, Array, String] Parsed response body
|
|
67
|
-
def put(path, body: nil, timeout: DEFAULT_TIMEOUT)
|
|
68
|
-
handle_response do
|
|
71
|
+
def put(path, body: nil, timeout: DEFAULT_TIMEOUT, detailed: false)
|
|
72
|
+
handle_response(detailed: detailed) do
|
|
69
73
|
@connection.put(normalize_path(path)) do |req|
|
|
70
74
|
req.body = body.to_json if body
|
|
71
75
|
req.options.timeout = timeout
|
|
@@ -76,11 +80,13 @@ module E2B
|
|
|
76
80
|
# Perform a DELETE request
|
|
77
81
|
#
|
|
78
82
|
# @param path [String] API endpoint path
|
|
83
|
+
# @param body [Hash, nil] Request body
|
|
79
84
|
# @param timeout [Integer] Request timeout in seconds
|
|
80
85
|
# @return [Hash, Array, String, nil] Parsed response body
|
|
81
|
-
def delete(path, timeout: DEFAULT_TIMEOUT)
|
|
82
|
-
handle_response do
|
|
86
|
+
def delete(path, body: nil, timeout: DEFAULT_TIMEOUT, detailed: false)
|
|
87
|
+
handle_response(detailed: detailed) do
|
|
83
88
|
@connection.delete(normalize_path(path)) do |req|
|
|
89
|
+
req.body = body.to_json if body
|
|
84
90
|
req.options.timeout = timeout
|
|
85
91
|
end
|
|
86
92
|
end
|
|
@@ -100,22 +106,31 @@ module E2B
|
|
|
100
106
|
conn.response :json, content_type: /\bjson$/
|
|
101
107
|
conn.adapter Faraday.default_adapter
|
|
102
108
|
|
|
103
|
-
conn.headers["X-API-Key"] = @api_key
|
|
109
|
+
conn.headers["X-API-Key"] = @api_key if @api_key && !@api_key.empty?
|
|
110
|
+
conn.headers["Authorization"] = "Bearer #{@access_token}" if @access_token && !@access_token.empty?
|
|
104
111
|
conn.headers["Content-Type"] = "application/json"
|
|
105
112
|
conn.headers["Accept"] = "application/json"
|
|
106
113
|
conn.headers["User-Agent"] = "e2b-ruby-sdk/#{E2B::VERSION}"
|
|
107
114
|
end
|
|
108
115
|
end
|
|
109
116
|
|
|
110
|
-
def handle_response
|
|
117
|
+
def handle_response(detailed: false)
|
|
111
118
|
response = yield
|
|
112
119
|
handle_error(response) unless response.success?
|
|
113
120
|
|
|
114
|
-
|
|
121
|
+
parsed_body = parse_body(response.body, response.headers)
|
|
122
|
+
return DetailedResponse.new(body: parsed_body, headers: response.headers.to_h) if detailed
|
|
123
|
+
|
|
124
|
+
parsed_body
|
|
125
|
+
rescue Faraday::TimeoutError => e
|
|
126
|
+
raise E2B::TimeoutError, "Request timed out: #{e.message}"
|
|
127
|
+
rescue Faraday::ConnectionFailed => e
|
|
128
|
+
raise E2B::E2BError, "Connection failed: #{e.message}"
|
|
129
|
+
end
|
|
115
130
|
|
|
116
|
-
|
|
131
|
+
def parse_body(body, headers)
|
|
117
132
|
if body.is_a?(String) && !body.empty?
|
|
118
|
-
content_type =
|
|
133
|
+
content_type = headers["content-type"] rescue "unknown"
|
|
119
134
|
if content_type&.include?("json") || body.start_with?("{", "[")
|
|
120
135
|
begin
|
|
121
136
|
return JSON.parse(body)
|
|
@@ -126,10 +141,6 @@ module E2B
|
|
|
126
141
|
end
|
|
127
142
|
|
|
128
143
|
body
|
|
129
|
-
rescue Faraday::TimeoutError => e
|
|
130
|
-
raise E2B::TimeoutError, "Request timed out: #{e.message}"
|
|
131
|
-
rescue Faraday::ConnectionFailed => e
|
|
132
|
-
raise E2B::E2BError, "Connection failed: #{e.message}"
|
|
133
144
|
end
|
|
134
145
|
|
|
135
146
|
def handle_error(response)
|
data/lib/e2b/client.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "rubygems/version"
|
|
4
|
+
|
|
3
5
|
module E2B
|
|
4
6
|
# Client for interacting with the E2B API
|
|
5
7
|
#
|
|
@@ -14,6 +16,8 @@ module E2B
|
|
|
14
16
|
# @example Using Sandbox directly (recommended, matches official SDK)
|
|
15
17
|
# sandbox = E2B::Sandbox.create(template: "base", api_key: "your-key")
|
|
16
18
|
class Client
|
|
19
|
+
include SandboxHelpers
|
|
20
|
+
|
|
17
21
|
# @return [Configuration] Client configuration
|
|
18
22
|
attr_reader :config
|
|
19
23
|
|
|
@@ -30,10 +34,11 @@ module E2B
|
|
|
30
34
|
@http_client = API::HttpClient.new(
|
|
31
35
|
base_url: @config.api_url,
|
|
32
36
|
api_key: @config.api_key,
|
|
37
|
+
access_token: @config.access_token,
|
|
33
38
|
logger: @config.logger
|
|
34
39
|
)
|
|
35
40
|
|
|
36
|
-
@domain =
|
|
41
|
+
@domain = @config.domain
|
|
37
42
|
end
|
|
38
43
|
|
|
39
44
|
# Create a new sandbox
|
|
@@ -44,7 +49,9 @@ module E2B
|
|
|
44
49
|
# @param metadata [Hash, nil] Custom metadata
|
|
45
50
|
# @param envs [Hash{String => String}, nil] Environment variables
|
|
46
51
|
# @return [Sandbox] The created sandbox instance
|
|
47
|
-
def create(template: "base", timeout: nil, timeout_ms: nil, metadata: nil, envs: nil,
|
|
52
|
+
def create(template: "base", timeout: nil, timeout_ms: nil, metadata: nil, envs: nil,
|
|
53
|
+
secure: true, allow_internet_access: true, network: nil,
|
|
54
|
+
lifecycle: nil, auto_pause: nil, mcp: nil, request_timeout: nil, **_opts)
|
|
48
55
|
# Support both seconds and milliseconds for backward compat
|
|
49
56
|
timeout_seconds = if timeout
|
|
50
57
|
timeout
|
|
@@ -53,22 +60,37 @@ module E2B
|
|
|
53
60
|
else
|
|
54
61
|
(@config.sandbox_timeout_ms / 1000).to_i
|
|
55
62
|
end
|
|
63
|
+
template = resolved_template(template, mcp: mcp)
|
|
64
|
+
lifecycle = normalized_lifecycle(lifecycle: lifecycle, auto_pause: auto_pause)
|
|
56
65
|
|
|
57
66
|
body = {
|
|
58
67
|
templateID: template,
|
|
59
|
-
timeout: timeout_seconds
|
|
68
|
+
timeout: timeout_seconds,
|
|
69
|
+
secure: secure,
|
|
70
|
+
allow_internet_access: allow_internet_access,
|
|
71
|
+
autoPause: lifecycle[:on_timeout] == "pause"
|
|
60
72
|
}
|
|
61
73
|
body[:metadata] = metadata if metadata
|
|
62
74
|
body[:envVars] = envs if envs
|
|
75
|
+
body[:mcp] = mcp if mcp
|
|
76
|
+
body[:network] = network if network
|
|
77
|
+
if body[:autoPause]
|
|
78
|
+
body[:autoResume] = { enabled: lifecycle[:auto_resume] }
|
|
79
|
+
end
|
|
63
80
|
|
|
64
|
-
response = @http_client.post("/sandboxes", body: body, timeout: 120)
|
|
81
|
+
response = @http_client.post("/sandboxes", body: body, timeout: request_timeout || @config.request_timeout || 120)
|
|
82
|
+
ensure_supported_envd_version!(response, @http_client)
|
|
65
83
|
|
|
66
|
-
Sandbox.new(
|
|
84
|
+
sandbox = Sandbox.new(
|
|
67
85
|
sandbox_data: response,
|
|
68
86
|
http_client: @http_client,
|
|
69
87
|
api_key: @config.api_key,
|
|
70
88
|
domain: @domain
|
|
71
89
|
)
|
|
90
|
+
|
|
91
|
+
start_mcp_gateway(sandbox, mcp) if mcp
|
|
92
|
+
|
|
93
|
+
sandbox
|
|
72
94
|
end
|
|
73
95
|
|
|
74
96
|
# Connect to an existing sandbox
|
|
@@ -77,12 +99,9 @@ module E2B
|
|
|
77
99
|
# @param timeout [Integer, nil] Timeout in seconds
|
|
78
100
|
# @return [Sandbox]
|
|
79
101
|
def connect(sandbox_id, timeout: nil)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
else
|
|
84
|
-
response = @http_client.get("/sandboxes/#{sandbox_id}")
|
|
85
|
-
end
|
|
102
|
+
timeout_seconds = timeout || ((@config.sandbox_timeout_ms || (Sandbox::DEFAULT_TIMEOUT * 1000)) / 1000).to_i
|
|
103
|
+
response = @http_client.post("/sandboxes/#{sandbox_id}/connect",
|
|
104
|
+
body: { timeout: timeout_seconds })
|
|
86
105
|
|
|
87
106
|
Sandbox.new(
|
|
88
107
|
sandbox_data: response,
|
|
@@ -112,29 +131,18 @@ module E2B
|
|
|
112
131
|
# @param metadata [Hash, nil] Filter by metadata
|
|
113
132
|
# @param state [String, nil] Filter by state
|
|
114
133
|
# @param limit [Integer] Maximum results
|
|
115
|
-
# @return [
|
|
116
|
-
def list(metadata: nil, state: nil, limit: 100)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
else
|
|
128
|
-
[]
|
|
129
|
-
end
|
|
130
|
-
Array(sandboxes).map do |sandbox_data|
|
|
131
|
-
Sandbox.new(
|
|
132
|
-
sandbox_data: sandbox_data,
|
|
133
|
-
http_client: @http_client,
|
|
134
|
-
api_key: @config.api_key,
|
|
135
|
-
domain: @domain
|
|
136
|
-
)
|
|
137
|
-
end
|
|
134
|
+
# @return [SandboxPaginator]
|
|
135
|
+
def list(metadata: nil, state: nil, limit: 100, next_token: nil)
|
|
136
|
+
query = {}
|
|
137
|
+
query[:metadata] = metadata if metadata
|
|
138
|
+
query[:state] = state if state
|
|
139
|
+
|
|
140
|
+
SandboxPaginator.new(
|
|
141
|
+
http_client: @http_client,
|
|
142
|
+
query: query.empty? ? nil : query,
|
|
143
|
+
limit: limit,
|
|
144
|
+
next_token: next_token
|
|
145
|
+
)
|
|
138
146
|
end
|
|
139
147
|
|
|
140
148
|
# Kill a sandbox
|
|
@@ -170,8 +178,8 @@ module E2B
|
|
|
170
178
|
# @param timeout [Integer, nil] New timeout in seconds
|
|
171
179
|
# @return [Sandbox]
|
|
172
180
|
def resume(sandbox_id, timeout: nil)
|
|
173
|
-
|
|
174
|
-
body
|
|
181
|
+
timeout_seconds = timeout || ((@config.sandbox_timeout_ms || (Sandbox::DEFAULT_TIMEOUT * 1000)) / 1000).to_i
|
|
182
|
+
body = { timeout: timeout_seconds }
|
|
175
183
|
|
|
176
184
|
response = @http_client.post("/sandboxes/#{sandbox_id}/connect", body: body)
|
|
177
185
|
|
|
@@ -183,6 +191,41 @@ module E2B
|
|
|
183
191
|
)
|
|
184
192
|
end
|
|
185
193
|
|
|
194
|
+
# Create a snapshot from an existing sandbox.
|
|
195
|
+
#
|
|
196
|
+
# @param sandbox_id [String] Source sandbox ID
|
|
197
|
+
# @return [Models::SnapshotInfo]
|
|
198
|
+
def create_snapshot(sandbox_id)
|
|
199
|
+
response = @http_client.post("/sandboxes/#{sandbox_id}/snapshots")
|
|
200
|
+
Models::SnapshotInfo.from_hash(response)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# List snapshots for the team, optionally filtered by source sandbox.
|
|
204
|
+
#
|
|
205
|
+
# @param sandbox_id [String, nil] Filter snapshots by source sandbox ID
|
|
206
|
+
# @param limit [Integer] Maximum results per page
|
|
207
|
+
# @param next_token [String, nil] Pagination token
|
|
208
|
+
# @return [SnapshotPaginator]
|
|
209
|
+
def list_snapshots(sandbox_id: nil, limit: 100, next_token: nil)
|
|
210
|
+
SnapshotPaginator.new(
|
|
211
|
+
http_client: @http_client,
|
|
212
|
+
sandbox_id: sandbox_id,
|
|
213
|
+
limit: limit,
|
|
214
|
+
next_token: next_token
|
|
215
|
+
)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Delete a snapshot template.
|
|
219
|
+
#
|
|
220
|
+
# @param snapshot_id [String] Snapshot identifier
|
|
221
|
+
# @return [Boolean]
|
|
222
|
+
def delete_snapshot(snapshot_id)
|
|
223
|
+
@http_client.delete("/templates/#{snapshot_id}")
|
|
224
|
+
true
|
|
225
|
+
rescue NotFoundError
|
|
226
|
+
false
|
|
227
|
+
end
|
|
228
|
+
|
|
186
229
|
private
|
|
187
230
|
|
|
188
231
|
def resolve_config(config_or_options)
|
data/lib/e2b/configuration.rb
CHANGED
|
@@ -16,12 +16,12 @@ module E2B
|
|
|
16
16
|
# config.request_timeout = 120
|
|
17
17
|
# end
|
|
18
18
|
class Configuration
|
|
19
|
-
# Default API base URL
|
|
20
|
-
DEFAULT_API_URL = "https://api.e2b.app"
|
|
21
|
-
|
|
22
19
|
# Default domain
|
|
23
20
|
DEFAULT_DOMAIN = "e2b.app"
|
|
24
21
|
|
|
22
|
+
# Default API base URL
|
|
23
|
+
DEFAULT_API_URL = "https://api.#{DEFAULT_DOMAIN}"
|
|
24
|
+
|
|
25
25
|
# Default request timeout in seconds
|
|
26
26
|
DEFAULT_REQUEST_TIMEOUT = 60
|
|
27
27
|
|
|
@@ -32,7 +32,7 @@ module E2B
|
|
|
32
32
|
DEFAULT_TIMEOUT_MS = 300_000
|
|
33
33
|
|
|
34
34
|
# Default sandbox timeout in milliseconds (backward compat)
|
|
35
|
-
DEFAULT_SANDBOX_TIMEOUT_MS =
|
|
35
|
+
DEFAULT_SANDBOX_TIMEOUT_MS = 300_000
|
|
36
36
|
|
|
37
37
|
# Maximum sandbox timeout (24 hours for Pro)
|
|
38
38
|
MAX_SANDBOX_TIMEOUT_MS = 86_400_000
|
|
@@ -90,11 +90,11 @@ module E2B
|
|
|
90
90
|
@api_key = api_key || ENV["E2B_API_KEY"]
|
|
91
91
|
@access_token = access_token || ENV["E2B_ACCESS_TOKEN"]
|
|
92
92
|
@domain = domain || ENV["E2B_DOMAIN"] || DEFAULT_DOMAIN
|
|
93
|
-
@
|
|
93
|
+
@debug = debug || ENV["E2B_DEBUG"]&.downcase == "true"
|
|
94
|
+
@api_url = api_url || ENV["E2B_API_URL"] || self.class.default_api_url(@domain, debug: @debug)
|
|
94
95
|
@request_timeout = request_timeout
|
|
95
96
|
@timeout_ms = timeout_ms
|
|
96
97
|
@sandbox_timeout_ms = sandbox_timeout_ms
|
|
97
|
-
@debug = debug || ENV["E2B_DEBUG"]&.downcase == "true"
|
|
98
98
|
@default_template = nil
|
|
99
99
|
@logger = nil
|
|
100
100
|
end
|
|
@@ -115,5 +115,11 @@ module E2B
|
|
|
115
115
|
def valid?
|
|
116
116
|
(!@api_key.nil? && !@api_key.empty?) || (!@access_token.nil? && !@access_token.empty?)
|
|
117
117
|
end
|
|
118
|
+
|
|
119
|
+
def self.default_api_url(domain, debug: false)
|
|
120
|
+
return "http://localhost:3000" if debug
|
|
121
|
+
|
|
122
|
+
"https://api.#{domain}"
|
|
123
|
+
end
|
|
118
124
|
end
|
|
119
125
|
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
|
|
6
|
+
module E2B
|
|
7
|
+
module DockerfileParser
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def parse(dockerfile_content_or_path, template_builder)
|
|
11
|
+
instructions = parse_instructions(read_dockerfile(dockerfile_content_or_path))
|
|
12
|
+
from_instructions = instructions.select { |instruction| instruction[:keyword] == "FROM" }
|
|
13
|
+
|
|
14
|
+
raise TemplateError, "Multi-stage Dockerfiles are not supported" if from_instructions.length > 1
|
|
15
|
+
raise TemplateError, "Dockerfile must contain a FROM instruction" if from_instructions.empty?
|
|
16
|
+
|
|
17
|
+
base_image = normalize_base_image(from_instructions.first[:value])
|
|
18
|
+
user_changed = false
|
|
19
|
+
workdir_changed = false
|
|
20
|
+
|
|
21
|
+
template_builder.set_user("root")
|
|
22
|
+
template_builder.set_workdir("/")
|
|
23
|
+
|
|
24
|
+
instructions.each do |instruction|
|
|
25
|
+
keyword = instruction[:keyword]
|
|
26
|
+
value = instruction[:value]
|
|
27
|
+
|
|
28
|
+
case keyword
|
|
29
|
+
when "FROM"
|
|
30
|
+
next
|
|
31
|
+
when "RUN"
|
|
32
|
+
handle_run(value, template_builder)
|
|
33
|
+
when "COPY", "ADD"
|
|
34
|
+
handle_copy(value, template_builder)
|
|
35
|
+
when "WORKDIR"
|
|
36
|
+
handle_workdir(value, template_builder)
|
|
37
|
+
workdir_changed = true
|
|
38
|
+
when "USER"
|
|
39
|
+
handle_user(value, template_builder)
|
|
40
|
+
user_changed = true
|
|
41
|
+
when "ENV", "ARG"
|
|
42
|
+
handle_env(value, keyword, template_builder)
|
|
43
|
+
when "CMD", "ENTRYPOINT"
|
|
44
|
+
handle_cmd(value, template_builder)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
template_builder.set_user("user") unless user_changed
|
|
49
|
+
template_builder.set_workdir("/home/user") unless workdir_changed
|
|
50
|
+
|
|
51
|
+
base_image
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def read_dockerfile(dockerfile_content_or_path)
|
|
55
|
+
if File.file?(dockerfile_content_or_path)
|
|
56
|
+
File.read(dockerfile_content_or_path)
|
|
57
|
+
else
|
|
58
|
+
dockerfile_content_or_path
|
|
59
|
+
end
|
|
60
|
+
rescue StandardError
|
|
61
|
+
dockerfile_content_or_path
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def parse_instructions(content)
|
|
65
|
+
instructions = []
|
|
66
|
+
current = +""
|
|
67
|
+
|
|
68
|
+
content.each_line do |raw_line|
|
|
69
|
+
line = raw_line.chomp
|
|
70
|
+
next if line.strip.empty? || line.lstrip.start_with?("#")
|
|
71
|
+
|
|
72
|
+
if line.rstrip.end_with?("\\")
|
|
73
|
+
current << line.rstrip.sub(/\\\s*\z/, "")
|
|
74
|
+
current << " "
|
|
75
|
+
next
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
current << line
|
|
79
|
+
instruction = current.strip
|
|
80
|
+
current = +""
|
|
81
|
+
next if instruction.empty?
|
|
82
|
+
|
|
83
|
+
keyword, value = instruction.split(/\s+/, 2)
|
|
84
|
+
instructions << { keyword: keyword.upcase, value: value.to_s.strip }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
unless current.strip.empty?
|
|
88
|
+
keyword, value = current.strip.split(/\s+/, 2)
|
|
89
|
+
instructions << { keyword: keyword.upcase, value: value.to_s.strip }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
instructions
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def normalize_base_image(value)
|
|
96
|
+
value.sub(/\s+as\s+.+\z/i, "").strip
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def handle_run(value, template_builder)
|
|
100
|
+
return if value.strip.empty?
|
|
101
|
+
|
|
102
|
+
template_builder.run_cmd(value.strip.gsub(/\s+/, " "))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def handle_copy(value, template_builder)
|
|
106
|
+
return if value.strip.empty?
|
|
107
|
+
|
|
108
|
+
parts = Shellwords.split(value)
|
|
109
|
+
user = nil
|
|
110
|
+
non_flag_parts = []
|
|
111
|
+
|
|
112
|
+
parts.each do |part|
|
|
113
|
+
if part.start_with?("--chown=")
|
|
114
|
+
user = part.delete_prefix("--chown=")
|
|
115
|
+
elsif !part.start_with?("--")
|
|
116
|
+
non_flag_parts << part
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
return unless non_flag_parts.length >= 2
|
|
121
|
+
|
|
122
|
+
src = non_flag_parts.first
|
|
123
|
+
dest = non_flag_parts.last
|
|
124
|
+
template_builder.copy(src, dest, user: user)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def handle_workdir(value, template_builder)
|
|
128
|
+
return if value.strip.empty?
|
|
129
|
+
|
|
130
|
+
template_builder.set_workdir(value.strip)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def handle_user(value, template_builder)
|
|
134
|
+
return if value.strip.empty?
|
|
135
|
+
|
|
136
|
+
template_builder.set_user(value.strip)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def handle_env(value, keyword, template_builder)
|
|
140
|
+
return if value.strip.empty?
|
|
141
|
+
|
|
142
|
+
parts = Shellwords.split(value)
|
|
143
|
+
envs = {}
|
|
144
|
+
|
|
145
|
+
if parts.length == 1
|
|
146
|
+
parse_single_env_part(parts.first, keyword, envs)
|
|
147
|
+
elsif parts.length == 2 && !(parts[0].include?("=") && parts[1].include?("="))
|
|
148
|
+
envs[parts[0]] = parts[1]
|
|
149
|
+
else
|
|
150
|
+
parts.each { |part| parse_single_env_part(part, keyword, envs) }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
template_builder.set_envs(envs) unless envs.empty?
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def parse_single_env_part(part, keyword, envs)
|
|
157
|
+
equal_index = part.index("=")
|
|
158
|
+
if equal_index && equal_index.positive?
|
|
159
|
+
envs[part[0...equal_index]] = part[(equal_index + 1)..]
|
|
160
|
+
elsif keyword == "ARG" && !part.strip.empty?
|
|
161
|
+
envs[part.strip] = ""
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def handle_cmd(value, template_builder)
|
|
166
|
+
return if value.strip.empty?
|
|
167
|
+
|
|
168
|
+
command = value.strip
|
|
169
|
+
begin
|
|
170
|
+
parsed = JSON.parse(command)
|
|
171
|
+
command = parsed.join(" ") if parsed.is_a?(Array)
|
|
172
|
+
rescue JSON::ParserError
|
|
173
|
+
nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
template_builder.set_start_cmd(command, E2B.wait_for_timeout(20_000))
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
data/lib/e2b/errors.rb
CHANGED
|
@@ -49,7 +49,30 @@ module E2B
|
|
|
49
49
|
class NotEnoughSpaceError < E2BError; end
|
|
50
50
|
|
|
51
51
|
# Error raised for template-related failures
|
|
52
|
-
class TemplateError < E2BError
|
|
52
|
+
class TemplateError < E2BError
|
|
53
|
+
attr_reader :source_location
|
|
54
|
+
|
|
55
|
+
def initialize(message = nil, status_code: nil, headers: {}, source_location: nil)
|
|
56
|
+
@source_location = source_location
|
|
57
|
+
super(message, status_code: status_code, headers: headers)
|
|
58
|
+
set_backtrace([source_location]) if source_location
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Error raised when a template build fails or returns an invalid status
|
|
63
|
+
class BuildError < E2BError
|
|
64
|
+
attr_reader :step, :source_location
|
|
65
|
+
|
|
66
|
+
def initialize(message = nil, status_code: nil, headers: {}, step: nil, source_location: nil)
|
|
67
|
+
@step = step
|
|
68
|
+
@source_location = source_location
|
|
69
|
+
super(message, status_code: status_code, headers: headers)
|
|
70
|
+
set_backtrace([source_location]) if source_location
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Error raised when file upload fails during a template build
|
|
75
|
+
class FileUploadError < BuildError; end
|
|
53
76
|
|
|
54
77
|
# Error raised when a command exits with non-zero exit code
|
|
55
78
|
#
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module E2B
|
|
4
|
+
module Models
|
|
5
|
+
class BuildInfo
|
|
6
|
+
attr_reader :alias_name, :name, :tags, :template_id, :build_id, :build_step_origins
|
|
7
|
+
|
|
8
|
+
def self.from_hash(data)
|
|
9
|
+
new(
|
|
10
|
+
alias_name: data["alias"] || data[:alias],
|
|
11
|
+
name: data["name"] || data[:name],
|
|
12
|
+
tags: data["tags"] || data[:tags] || [],
|
|
13
|
+
template_id: data["templateID"] || data["template_id"] || data[:templateID],
|
|
14
|
+
build_id: data["buildID"] || data["build_id"] || data[:buildID],
|
|
15
|
+
build_step_origins: data["buildStepOrigins"] || data["build_step_origins"] || data[:buildStepOrigins] || []
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(alias_name:, name:, tags:, template_id:, build_id:, build_step_origins: [])
|
|
20
|
+
@alias_name = alias_name
|
|
21
|
+
@name = name
|
|
22
|
+
@tags = tags || []
|
|
23
|
+
@template_id = template_id
|
|
24
|
+
@build_id = build_id
|
|
25
|
+
@build_step_origins = Array(build_step_origins).compact
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module E2B
|
|
4
|
+
module Models
|
|
5
|
+
class BuildStatusReason
|
|
6
|
+
attr_reader :message, :step, :log_entries
|
|
7
|
+
|
|
8
|
+
def self.from_hash(data)
|
|
9
|
+
return nil if data.nil?
|
|
10
|
+
|
|
11
|
+
new(
|
|
12
|
+
message: data["message"] || data[:message],
|
|
13
|
+
step: data["step"] || data[:step],
|
|
14
|
+
log_entries: Array(data["logEntries"] || data["log_entries"] || data[:logEntries]).map do |entry|
|
|
15
|
+
TemplateLogEntry.from_hash(entry)
|
|
16
|
+
end
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(message:, step: nil, log_entries: [])
|
|
21
|
+
@message = message
|
|
22
|
+
@step = step
|
|
23
|
+
@log_entries = log_entries || []
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|