daytona-sdk 0.125.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.
@@ -0,0 +1,376 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'timeout'
4
+
5
+ module Daytona
6
+ class Sandbox # rubocop:disable Metrics/ClassLength
7
+ DEFAULT_TIMEOUT = 60
8
+
9
+ # @return [String] The ID of the sandbox
10
+ attr_reader :id
11
+
12
+ # @return [String] The organization ID of the sandbox
13
+ attr_reader :organization_id
14
+
15
+ # @return [String] The snapshot used for the sandbox
16
+ attr_reader :snapshot
17
+
18
+ # @return [String] The user associated with the project
19
+ attr_reader :user
20
+
21
+ # @return [Hash<String, String>] Environment variables for the sandbox
22
+ attr_reader :env
23
+
24
+ # @return [Hash<String, String>] Labels for the sandbox
25
+ attr_reader :labels
26
+
27
+ # @return [Boolean] Whether the sandbox http preview is public
28
+ attr_reader :public
29
+
30
+ # @return [Boolean] Whether to block all network access for the sandbox
31
+ attr_reader :network_block_all
32
+
33
+ # @return [String] Comma-separated list of allowed CIDR network addresses for the sandbox
34
+ attr_reader :network_allow_list
35
+
36
+ # @return [String] The target environment for the sandbox
37
+ attr_reader :target
38
+
39
+ # @return [Float] The CPU quota for the sandbox
40
+ attr_reader :cpu
41
+
42
+ # @return [Float] The GPU quota for the sandbox
43
+ attr_reader :gpu
44
+
45
+ # @return [Float] The memory quota for the sandbox
46
+ attr_reader :memory
47
+
48
+ # @return [Float] The disk quota for the sandbox
49
+ attr_reader :disk
50
+
51
+ # @return [DaytonaApiClient::SandboxState] The state of the sandbox
52
+ attr_reader :state
53
+
54
+ # @return [DaytonaApiClient::SandboxDesiredState] The desired state of the sandbox
55
+ attr_reader :desired_state
56
+
57
+ # @return [String] The error reason of the sandbox
58
+ attr_reader :error_reason
59
+
60
+ # @return [String] The state of the backup
61
+ attr_reader :backup_state
62
+
63
+ # @return [String] The creation timestamp of the last backup
64
+ attr_reader :backup_created_at
65
+
66
+ # @return [Float] Auto-stop interval in minutes (0 means disabled)
67
+ attr_reader :auto_stop_interval
68
+
69
+ # @return [Float] Auto-archive interval in minutes
70
+ attr_reader :auto_archive_interval
71
+
72
+ # @return [Float] Auto-delete interval in minutes
73
+ # (negative value means disabled, 0 means delete immediately upon stopping)
74
+ attr_reader :auto_delete_interval
75
+
76
+ # @return [String] The domain name of the runner
77
+ attr_reader :runner_domain
78
+
79
+ # @return [Array<DaytonaApiClient::SandboxVolume>] Array of volumes attached to the sandbox
80
+ attr_reader :volumes
81
+
82
+ # @return [DaytonaApiClient::BuildInfo] Build information for the sandbox
83
+ attr_reader :build_info
84
+
85
+ # @return [String] The creation timestamp of the sandbox
86
+ attr_reader :created_at
87
+
88
+ # @return [String] The last update timestamp of the sandbox
89
+ attr_reader :updated_at
90
+
91
+ # @return [String] The version of the daemon running in the sandbox
92
+ attr_reader :daemon_version
93
+
94
+ # @return [Daytona::SandboxPythonCodeToolbox, Daytona::SandboxTsCodeToolbox]
95
+ attr_reader :code_toolbox
96
+
97
+ # @return [Daytona::Config]
98
+ attr_reader :config
99
+
100
+ # @return [DaytonaApiClient::SandboxApi]
101
+ attr_reader :sandbox_api
102
+
103
+ # @return [DaytonaApiClient::ToolboxApi]
104
+ attr_reader :toolbox_api
105
+
106
+ # @return [Daytona::Process]
107
+ attr_reader :process
108
+
109
+ # @return [Daytona::FileSystem]
110
+ attr_reader :fs
111
+
112
+ # @return [Daytona::Git]
113
+ attr_reader :git
114
+
115
+ # @return [Daytona::ComputerUse]
116
+ attr_reader :computer_use
117
+
118
+ # @params code_toolbox [Daytona::SandboxPythonCodeToolbox, Daytona::SandboxTsCodeToolbox]
119
+ # @params config [Daytona::Config]
120
+ # @params sandbox_api [DaytonaApiClient::SandboxApi]
121
+ # @params sandbox_dto [DaytonaApiClient::Sandbox]
122
+ # @params toolbox_api [DaytonaApiClient::ToolboxApi]
123
+ def initialize(code_toolbox:, sandbox_dto:, config:, sandbox_api:, toolbox_api:) # rubocop:disable Metrics/MethodLength
124
+ process_response(sandbox_dto)
125
+ @code_toolbox = code_toolbox
126
+ @config = config
127
+ @sandbox_api = sandbox_api
128
+ @toolbox_api = toolbox_api
129
+ @process = Process.new(
130
+ sandbox_id: id,
131
+ code_toolbox:,
132
+ toolbox_api:,
133
+ get_preview_link: proc { |port| preview_url(port) }
134
+ )
135
+ @fs = FileSystem.new(sandbox_id: id, toolbox_api:)
136
+ @git = Git.new(sandbox_id: id, toolbox_api:)
137
+ @computer_use = ComputerUse.new(sandbox_id: id, toolbox_api:)
138
+ end
139
+
140
+ # Archives the sandbox, making it inactive and preserving its state. When sandboxes are
141
+ # archived, the entire filesystem state is moved to cost-effective object storage, making it
142
+ # possible to keep sandboxes available for an extended period. The tradeoff between archived
143
+ # and stopped states is that starting an archived sandbox takes more time, depending on its size.
144
+ # Sandbox must be stopped before archiving.
145
+ #
146
+ # @return [void]
147
+ def archive
148
+ sandbox_api.archive_sandbox(id)
149
+ refresh
150
+ end
151
+
152
+ # Sets the auto-archive interval for the Sandbox.
153
+ # The Sandbox will automatically archive after being continuously stopped for the specified interval.
154
+ #
155
+ # @param interval [Integer]
156
+ # @return [Integer]
157
+ # @raise [Daytona:Sdk::Error]
158
+ def auto_archive_interval=(interval)
159
+ raise Sdk::Error, 'Auto-archive interval must be a non-negative integer' if interval.negative?
160
+
161
+ sandbox_api.set_auto_archive_interval(id, interval)
162
+ @auto_archive_interval = interval
163
+ end
164
+
165
+ # Sets the auto-delete interval for the Sandbox.
166
+ # The Sandbox will automatically delete after being continuously stopped for the specified interval.
167
+ #
168
+ # @param interval [Integer]
169
+ # @return [Integer]
170
+ # @raise [Daytona:Sdk::Error]
171
+ def auto_delete_interval=(interval)
172
+ sandbox_api.set_auto_delete_interval(id, interval)
173
+ @auto_delete_interval = interval
174
+ end
175
+
176
+ # Sets the auto-stop interval for the Sandbox.
177
+ # The Sandbox will automatically stop after being idle (no new events) for the specified interval.
178
+ # Events include any state changes or interactions with the Sandbox through the SDK.
179
+ # Interactions using Sandbox Previews are not included.
180
+ #
181
+ # @param interval [Integer]
182
+ # @return [Integer]
183
+ # @raise [Daytona:Sdk::Error]
184
+ def auto_stop_interval=(interval)
185
+ raise Sdk::Error, 'Auto-stop interval must be a non-negative integer' if interval.negative?
186
+
187
+ sandbox_api.set_autostop_interval(id, interval)
188
+ @auto_stop_interval = interval
189
+ end
190
+
191
+ # Creates an SSH access token for the sandbox.
192
+ #
193
+ # @param expires_in_minutes [Integer] TThe number of minutes the SSH access token will be valid for
194
+ # @return [DaytonaApiClient::SshAccessDto]
195
+ def create_ssh_access(expires_in_minutes) = sandbox_api.create_ssh_access(id, { expires_in_minutes: })
196
+
197
+ # @return [void]
198
+ def delete
199
+ sandbox_api.delete_sandbox(id)
200
+ refresh
201
+ end
202
+
203
+ # Sets labels for the Sandbox.
204
+ #
205
+ # @param labels [Hash<String, String>]
206
+ # @return [Hash<String, String>]
207
+ def labels=(labels)
208
+ @labels = sandbox_api.replace_labels(id, DaytonaApiClient::SandboxLabels.build_from_hash(labels:)).labels
209
+ end
210
+
211
+ # Retrieves the preview link for the sandbox at the specified port. If the port is closed,
212
+ # it will be opened automatically. For private sandboxes, a token is included to grant access
213
+ # to the URL.
214
+ #
215
+ # @param port [Integer]
216
+ # @return [DaytonaApiClient::PortPreviewUrl]
217
+ def preview_url(port) = sandbox_api.get_port_preview_url(id, port)
218
+
219
+ # Refresh the Sandbox data from the API.
220
+ #
221
+ # @return [void]
222
+ def refresh = process_response(sandbox_api.get_sandbox(id))
223
+
224
+ # Revokes an SSH access token for the sandbox.
225
+ #
226
+ # @param token [String]
227
+ # @return [void]
228
+ def revoke_ssh_access(token) = sandbox_api.revoke_ssh_access(id, token:)
229
+
230
+ # Starts the Sandbox and waits for it to be ready.
231
+ #
232
+ # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
233
+ # @return [void]
234
+ def start(timeout = DEFAULT_TIMEOUT)
235
+ with_timeout(
236
+ timeout:,
237
+ message: "Sandbox #{id} failed to become ready within the #{timeout} seconds timeout period",
238
+ setup: proc { process_response(sandbox_api.start_sandbox(id)) }
239
+ ) { wait_for_states(operation: OPERATION_START, target_states: [DaytonaApiClient::SandboxState::STARTED]) }
240
+ end
241
+
242
+ # Stops the Sandbox and waits for it to be stopped.
243
+ #
244
+ # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
245
+ # @return [void]
246
+ def stop(timeout = DEFAULT_TIMEOUT) # rubocop:disable Metrics/MethodLength
247
+ with_timeout(
248
+ timeout:,
249
+ message: "Sandbox #{id} failed to become stopped within the #{timeout} seconds timeout period",
250
+ setup: proc {
251
+ sandbox_api.stop_sandbox(id)
252
+ refresh
253
+ }
254
+ ) do
255
+ wait_for_states(
256
+ operation: OPERATION_STOP,
257
+ target_states: [DaytonaApiClient::SandboxState::STOPPED, DaytonaApiClient::SandboxState::DESTROYED]
258
+ )
259
+ end
260
+ end
261
+
262
+ # Creates a new Language Server Protocol (LSP) server instance.
263
+ # The LSP server provides language-specific features like code completion,
264
+ # diagnostics, and more.
265
+ #
266
+ # @param language_id [Symbol] The language server type (e.g., Daytona::LspServer::Language::PYTHON)
267
+ # @param path_to_project [String] Path to the project root directory. Relative paths are resolved
268
+ # based on the sandbox working directory.
269
+ # @return [Daytona::LspServer]
270
+ def create_lsp_server(language_id:, path_to_project:)
271
+ LspServer.new(language_id:, path_to_project:, toolbox_api:, sandbox_id: id)
272
+ end
273
+
274
+ # Validates an SSH access token for the sandbox.
275
+ #
276
+ # @param token [String]
277
+ # @return [DaytonaApiClient::SshAccessValidationDto]
278
+ def validate_ssh_access(token) = sandbox_api.validate_ssh_access(token)
279
+
280
+ # Waits for the Sandbox to reach the 'started' state. Polls the Sandbox status until it
281
+ # reaches the 'started' state or encounters an error.
282
+ #
283
+ # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
284
+ # @return [void]
285
+ def wait_for_sandbox_start(_timeout = DEFAULT_TIMEOUT)
286
+ wait_for_states(operation: OPERATION_START, target_states: [DaytonaApiClient::SandboxState::STARTED])
287
+ end
288
+
289
+ private
290
+
291
+ # @params sandbox_dto [DaytonaApiClient::Sandbox]
292
+ # @return [void]
293
+ def process_response(sandbox_dto) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
294
+ @id = sandbox_dto.id
295
+ @organization_id = sandbox_dto.organization_id
296
+ @snapshot = sandbox_dto.snapshot
297
+ @user = sandbox_dto.user
298
+ @env = sandbox_dto.env
299
+ @labels = sandbox_dto.labels
300
+ @public = sandbox_dto.public
301
+ @target = sandbox_dto.target
302
+ @cpu = sandbox_dto.cpu
303
+ @gpu = sandbox_dto.gpu
304
+ @memory = sandbox_dto.memory
305
+ @disk = sandbox_dto.disk
306
+ @state = sandbox_dto.state
307
+ @desired_state = sandbox_dto.desired_state
308
+ @error_reason = sandbox_dto.error_reason
309
+ @backup_state = sandbox_dto.backup_state
310
+ @backup_created_at = sandbox_dto.backup_created_at
311
+ @auto_stop_interval = sandbox_dto.auto_stop_interval
312
+ @auto_archive_interval = sandbox_dto.auto_archive_interval
313
+ @auto_delete_interval = sandbox_dto.auto_delete_interval
314
+ @runner_domain = sandbox_dto.runner_domain
315
+ @volumes = sandbox_dto.volumes
316
+ @build_info = sandbox_dto.build_info
317
+ @created_at = sandbox_dto.created_at
318
+ @updated_at = sandbox_dto.updated_at
319
+ @daemon_version = sandbox_dto.daemon_version
320
+ @network_block_all = sandbox_dto.network_block_all
321
+ @network_allow_list = sandbox_dto.network_allow_list
322
+ end
323
+
324
+ # Monitors block not to exceed max execution time.
325
+ #
326
+ # @param setup [#call, Nil] Optional setup block
327
+ # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s)
328
+ # @param message [String] Error message
329
+ # @return [void]
330
+ # @raise [Daytona::Sdk::Error]
331
+ def with_timeout(message:, setup:, timeout: DEFAULT_TIMEOUT, &)
332
+ start_at = Time.now
333
+ setup&.call
334
+
335
+ Timeout.timeout(
336
+ setup ? [NO_TIMEOUT, timeout - (Time.now - start_at)].max : timeout,
337
+ Sdk::Error,
338
+ message,
339
+ &
340
+ )
341
+ end
342
+
343
+ # Waits for the Sandbox to reach the one of the target states. Polls the Sandbox status until it
344
+ # reaches the one of the target states or encounters an error. It will wait up to 60 seconds
345
+ # for the Sandbox to reach one of the target states.
346
+ #
347
+ # @param operation [#to_s] Operation name for error message
348
+ # @param target_states [Array<DaytonaApiClient::SandboxState>] List of the target states
349
+ # @return [void]
350
+ # @raise [Daytona::Sdk::Error]
351
+ def wait_for_states(operation:, target_states:)
352
+ loop do
353
+ case state
354
+ when *target_states then return
355
+ when DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED
356
+ raise Sdk::Error, "Sandbox #{id} failed to #{operation} with state: #{state}, error reason: #{error_reason}"
357
+ end
358
+
359
+ sleep(IDLE_DURATION)
360
+ refresh
361
+ end
362
+ end
363
+
364
+ IDLE_DURATION = 0.1
365
+ private_constant :IDLE_DURATION
366
+
367
+ NO_TIMEOUT = 0
368
+ private_constant :NO_TIMEOUT
369
+
370
+ OPERATION_START = :start
371
+ private_constant :OPERATION_START
372
+
373
+ OPERATION_STOP = :stop
374
+ private_constant :OPERATION_STOP
375
+ end
376
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Daytona
4
+ module Sdk
5
+ VERSION = '0.125.0'
6
+ end
7
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ require 'dotenv'
6
+ Dotenv.load('.env.local', '.env')
7
+ require 'daytona_api_client'
8
+ require 'toml'
9
+ require 'websocket-client-simple'
10
+
11
+ require_relative 'sdk/version'
12
+ require_relative 'config'
13
+ require_relative 'common/charts'
14
+ require_relative 'common/code_language'
15
+ require_relative 'common/daytona'
16
+ require_relative 'common/file_system'
17
+ require_relative 'common/image'
18
+ require_relative 'common/git'
19
+ require_relative 'common/process'
20
+ require_relative 'common/pty'
21
+ require_relative 'common/resources'
22
+ require_relative 'common/response'
23
+ require_relative 'common/snapshot'
24
+ require_relative 'computer_use'
25
+ require_relative 'code_toolbox/sandbox_python_code_toolbox'
26
+ require_relative 'code_toolbox/sandbox_ts_code_toolbox'
27
+ require_relative 'daytona'
28
+ require_relative 'file_system'
29
+ require_relative 'git'
30
+ require_relative 'lsp_server'
31
+ require_relative 'object_storage'
32
+ require_relative 'sandbox'
33
+ require_relative 'snapshot_service'
34
+ require_relative 'util'
35
+ require_relative 'volume'
36
+ require_relative 'volume_service'
37
+ require_relative 'process'
38
+
39
+ module Daytona
40
+ module Sdk
41
+ class Error < StandardError; end
42
+
43
+ def self.logger = @logger ||= Logger.new($stdout, level: Logger::INFO)
44
+ end
45
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module Daytona
6
+ class SnapshotService
7
+ SNAPSHOTS_FETCH_LIMIT = 200
8
+
9
+ # @param snapshots_api [DaytonaApiClient::SnapshotsApi] The snapshots API client
10
+ # @param object_storage_api [DaytonaApiClient::ObjectStorageApi] The object storage API client
11
+ def initialize(snapshots_api:, object_storage_api:)
12
+ @snapshots_api = snapshots_api
13
+ @object_storage_api = object_storage_api
14
+ end
15
+
16
+ # List all Snapshots.
17
+ #
18
+ # @param page [Integer, Nil]
19
+ # @param limit [Integer, Nil]
20
+ # @return [Daytona::PaginatedResource] Paginated list of all Snapshots
21
+ # @raise [Daytona::Sdk::Error]
22
+ #
23
+ # @example
24
+ # daytona = Daytona::Daytona.new
25
+ # response = daytona.snapshot.list(page: 1, limit: 10)
26
+ # snapshots.items.each { |snapshot| puts "#{snapshot.name} (#{snapshot.image_name})" }
27
+ def list(page: nil, limit: nil)
28
+ raise Sdk::Error, 'page must be positive integer' if page && page < 1
29
+
30
+ raise Sdk::Error, 'limit must be positive integer' if limit && limit < 1
31
+
32
+ response = snapshots_api.get_all_snapshots(page:, limit:)
33
+ PaginatedResource.new(
34
+ total: response.total,
35
+ page: response.page,
36
+ total_pages: response.total_pages,
37
+ items: response.items.map { |snapshot_dto| Snapshot.from_dto(snapshot_dto) }
38
+ )
39
+ end
40
+
41
+ # Delete a Snapshot.
42
+ #
43
+ # @param snapshot [Daytona::Snapshot] Snapshot to delete
44
+ # @return [void]
45
+ #
46
+ # @example
47
+ # daytona = Daytona::Daytona.new
48
+ # snapshot = daytona.snapshot.get("demo")
49
+ # daytona.snapshot.delete(snapshot)
50
+ # puts "Snapshot deleted"
51
+ def delete(snapshot) = snapshots_api.remove_snapshot(snapshot.id)
52
+
53
+ # Get a Snapshot by name.
54
+ #
55
+ # @param name [String] Name of the Snapshot to get
56
+ # @return [Daytona::Snapshot] The Snapshot object
57
+ #
58
+ # @example
59
+ # daytona = Daytona::Daytona.new
60
+ # snapshot = daytona.snapshot.get("demo")
61
+ # puts "#{snapshot.name} (#{snapshot.image_name})"
62
+ def get(name) = Snapshot.from_dto(snapshots_api.get_snapshot(name))
63
+
64
+ # Creates and registers a new snapshot from the given Image definition.
65
+ #
66
+ # @param params [Daytona::CreateSnapshotParams] Parameters for snapshot creation
67
+ # @param on_logs [Proc, Nil] Callback proc handling snapshot creation logs
68
+ # @return [Daytona::Snapshot] The created snapshot
69
+ #
70
+ # @example
71
+ # image = Image.debianSlim('3.12').pipInstall('numpy')
72
+ # params = CreateSnapshotParams.new(name: 'my-snapshot', image: image)
73
+ # snapshot = daytona.snapshot.create(params) do |chunk|
74
+ # print chunk
75
+ # end
76
+ def create(params, on_logs: nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
77
+ create_snapshot_req = DaytonaApiClient::CreateSnapshot.new(name: params.name)
78
+
79
+ if params.image.is_a?(String)
80
+ create_snapshot_req.image_name = params.image
81
+ create_snapshot_req.entrypoint = params.entrypoint
82
+ else
83
+ create_snapshot_req.build_info = DaytonaApiClient::CreateBuildInfo.new(
84
+ context_hashes: self.class.process_image_context(object_storage_api, params.image),
85
+ dockerfile_content: if params.entrypoint
86
+ params.image.entrypoint(params.entrypoint).dockerfile
87
+ else
88
+ params.image.dockerfile
89
+ end
90
+ )
91
+ end
92
+
93
+ if params.resources
94
+ create_snapshot_req.cpu = params.resources.cpu
95
+ create_snapshot_req.gpu = params.resources.gpu
96
+ create_snapshot_req.memory = params.resources.memory
97
+ create_snapshot_req.disk = params.resources.disk
98
+ end
99
+
100
+ snapshot = snapshots_api.create_snapshot(create_snapshot_req)
101
+
102
+ snapshot = stream_logs(snapshot, on_logs:) if on_logs
103
+
104
+ if [DaytonaApiClient::SnapshotState::ERROR, DaytonaApiClient::SnapshotState::BUILD_FAILED].include?(snapshot.state)
105
+ raise Sdk::Error, "Failed to create snapshot #{snapshot.name}, reason: #{snapshot.error_reason}"
106
+ end
107
+
108
+ Snapshot.from_dto(snapshot)
109
+ end
110
+
111
+ # Activate a snapshot
112
+ #
113
+ # @param snapshot [Daytona::Snapshot] The snapshot instance
114
+ # @return [Daytona::Snapshot]
115
+ def activate(snapshot) = Snapshot.from_dto(snapshots_api.activate_snapshot(snapshot.id))
116
+
117
+ # Processes the image context by uploading it to object storage
118
+ #
119
+ # @param image [Daytona::Image] The Image instance
120
+ # @return [Array<String>] List of context hashes stored in object storage
121
+ def self.process_image_context(object_storage_api, image) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
122
+ return [] unless image.context_list && !image.context_list.empty?
123
+
124
+ push_access_creds = object_storage_api.get_push_access
125
+
126
+ object_storage = ObjectStorage.new(
127
+ endpoint_url: push_access_creds.storage_url,
128
+ aws_access_key_id: push_access_creds.access_key,
129
+ aws_secret_access_key: push_access_creds.secret,
130
+ aws_session_token: push_access_creds.session_token,
131
+ bucket_name: push_access_creds.bucket
132
+ )
133
+
134
+ image.context_list.map do |context|
135
+ object_storage.upload(
136
+ context.source_path,
137
+ push_access_creds.organization_id,
138
+ context.archive_path
139
+ )
140
+ end
141
+ end
142
+
143
+ private
144
+
145
+ # @return [DaytonaApiClient::SnapshotsApi] The snapshots API client
146
+ attr_reader :snapshots_api
147
+
148
+ # @return [DaytonaApiClient::ObjectStorageApi, nil] The object storage API client
149
+ attr_reader :object_storage_api
150
+
151
+ # @param snapshot [DaytonaApiClient::SnapshotDto]
152
+ # @param on_logs [Proc]
153
+ # @return [DaytonaApiClient::SnapshotDto]
154
+ def stream_logs(snapshot, on_logs:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
155
+ terminal_states = [
156
+ DaytonaApiClient::SnapshotState::ACTIVE,
157
+ DaytonaApiClient::SnapshotState::ERROR,
158
+ DaytonaApiClient::SnapshotState::BUILD_FAILED
159
+ ]
160
+
161
+ thread = nil
162
+ previous_state = snapshot.state
163
+ until terminal_states.include?(snapshot.state)
164
+ Sdk.logger.debug("Waiting for snapshot to be created: #{snapshot.state}")
165
+ if thread.nil? && snapshot.state != DaytonaApiClient::SnapshotState::BUILD_PENDING
166
+ thread = start_log_streaming(snapshot, on_logs:)
167
+ end
168
+
169
+ on_logs.call("Creating snapshot #{snapshot.name} (#{snapshot.state})") if previous_state != snapshot.state
170
+
171
+ sleep(1)
172
+ previous_state = snapshot.state
173
+ snapshot = snapshots_api.get_snapshot(snapshot.id)
174
+ end
175
+
176
+ thread&.join
177
+
178
+ if snapshot.state == DaytonaApiClient::SnapshotState::ACTIVE
179
+ on_logs.call("Created snapshot #{snapshot.name} (#{snapshot.state})")
180
+ end
181
+
182
+ snapshot
183
+ end
184
+
185
+ # @param snapshot [DaytonaApiClient::SnapshotDto]
186
+ # @param on_logs [Proc]
187
+ # @return [Thread]
188
+ def start_log_streaming(snapshot, on_logs:)
189
+ uri = URI.parse(snapshots_api.api_client.config.base_url)
190
+ uri.path = "/api/snapshots/#{snapshot.id}/build-logs"
191
+ uri.query = 'follow=true'
192
+
193
+ headers = {}
194
+ snapshots_api.api_client.update_params_for_auth!(headers, nil, ['bearer'])
195
+ Util.stream_async(uri:, headers:, on_chunk: on_logs)
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+
5
+ module Daytona
6
+ module Util
7
+ def self.demux(line) # rubocop:disable Metrics/MethodLength
8
+ stdout = ''.dup
9
+ stderr = ''.dup
10
+
11
+ until line.empty?
12
+ buff = line.start_with?(STDOUT_PREFIX) ? stdout : stderr
13
+ line = line[3..]
14
+
15
+ end_index = [
16
+ line.index(STDOUT_PREFIX),
17
+ line.index(STDERR_PREFIX)
18
+ ].compact.min || line.length
19
+ data = line[...end_index]
20
+ buff << data
21
+
22
+ line = line[end_index..]
23
+ end
24
+
25
+ [stdout, stderr]
26
+ end
27
+
28
+ # @param uri [URI]
29
+ # @param on_chunk [Proc]
30
+ # @param headers [Hash<String, String>]
31
+ # @return [Thread]
32
+ def self.stream_async(uri:, on_chunk:, headers: nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
33
+ Sdk.logger.debug("Starting async stream: #{uri}")
34
+ Thread.new do
35
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
36
+ request = Net::HTTP::Get.new(uri, headers)
37
+
38
+ http.request(request) do |response|
39
+ response.read_body do |chunk|
40
+ Sdk.logger.debug("Chunked response received: #{chunk.inspect}")
41
+ on_chunk.call(chunk)
42
+ end
43
+ end
44
+ end
45
+ rescue Net::ReadTimeout => e
46
+ Sdk.logger.debug("Async stream (#{uri}) timeout: #{e.inspect}")
47
+ end
48
+ end
49
+
50
+ STDOUT_PREFIX = "\x01\x01\01"
51
+ private_constant :STDOUT_PREFIX
52
+
53
+ STDERR_PREFIX = "\x02\x02\02"
54
+ private_constant :STDERR_PREFIX
55
+ end
56
+ end