daytona 0.200.0 → 0.201.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/lib/daytona/code_interpreter.rb +6 -6
- data/lib/daytona/common/event_subscription_manager.rb +110 -60
- data/lib/daytona/common/response.rb +2 -0
- data/lib/daytona/computer_use.rb +62 -62
- data/lib/daytona/daytona.rb +9 -0
- data/lib/daytona/file_system.rb +36 -28
- data/lib/daytona/git.rb +21 -31
- data/lib/daytona/lsp_server.rb +20 -2
- data/lib/daytona/object_storage.rb +2 -5
- data/lib/daytona/process.rb +44 -4
- data/lib/daytona/sandbox.rb +93 -9
- data/lib/daytona/sdk/deprecated.rb +18 -0
- data/lib/daytona/sdk/errors.rb +279 -0
- data/lib/daytona/sdk/file_download_patch.rb +131 -0
- data/lib/daytona/sdk/version.rb +1 -1
- data/lib/daytona/sdk.rb +7 -9
- data/lib/daytona/snapshot_service.rb +2 -1
- data/lib/daytona/utils/file_url_signing.rb +56 -0
- metadata +19 -9
data/lib/daytona/sandbox.rb
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
require 'time'
|
|
7
7
|
require 'timeout'
|
|
8
|
+
require_relative 'utils/file_url_signing'
|
|
8
9
|
|
|
9
10
|
module Daytona
|
|
10
11
|
# A single point-in-time sample of historical Sandbox resource usage.
|
|
@@ -196,6 +197,8 @@ module Daytona
|
|
|
196
197
|
@analytics_api_url_provider = analytics_api_url_provider
|
|
197
198
|
@subscription_manager = subscription_manager
|
|
198
199
|
@sub_id = nil
|
|
200
|
+
@signing_key = nil
|
|
201
|
+
@signing_key_fetched_at = 0
|
|
199
202
|
|
|
200
203
|
# Create toolbox API clients with dynamic configuration
|
|
201
204
|
@toolbox_api_config = build_toolbox_api_config
|
|
@@ -435,8 +438,8 @@ module Daytona
|
|
|
435
438
|
# puts "Sandbox user home: #{user_home_dir}"
|
|
436
439
|
def get_user_home_dir
|
|
437
440
|
@info_api.get_user_home_dir.dir
|
|
438
|
-
rescue
|
|
439
|
-
raise Sdk
|
|
441
|
+
rescue *Sdk::API_ERROR_CLASSES => e
|
|
442
|
+
raise Sdk.wrap_error(e, 'Failed to get user home directory')
|
|
440
443
|
end
|
|
441
444
|
|
|
442
445
|
# Gets the working directory path inside the Sandbox.
|
|
@@ -449,8 +452,8 @@ module Daytona
|
|
|
449
452
|
# puts "Sandbox working directory: #{work_dir}"
|
|
450
453
|
def get_work_dir
|
|
451
454
|
@info_api.get_work_dir.dir
|
|
452
|
-
rescue
|
|
453
|
-
raise Sdk
|
|
455
|
+
rescue *Sdk::API_ERROR_CLASSES => e
|
|
456
|
+
raise Sdk.wrap_error(e, 'Failed to get working directory path')
|
|
454
457
|
end
|
|
455
458
|
|
|
456
459
|
# Updates the Sandbox daemon's process environment. Newly spawned processes, sessions
|
|
@@ -524,6 +527,76 @@ module Daytona
|
|
|
524
527
|
sandbox_api.get_port_preview_url(id, port)
|
|
525
528
|
end
|
|
526
529
|
|
|
530
|
+
# Creates a pre-signed URL for downloading a file from the Sandbox.
|
|
531
|
+
#
|
|
532
|
+
# The URL works with any HTTP client without auth headers and stays valid across
|
|
533
|
+
# sandbox restarts (downloads succeed only while the sandbox is running). The signing
|
|
534
|
+
# key is cached locally for up to 15 seconds; if the key was rotated from another
|
|
535
|
+
# client, URLs may be rejected until the cache refreshes.
|
|
536
|
+
#
|
|
537
|
+
# @param path [String] Path to the file in the Sandbox.
|
|
538
|
+
# @param ttl_seconds [Integer, nil] How long the URL stays valid, in seconds.
|
|
539
|
+
# Defaults to 3600. Zero or negative means the URL never expires.
|
|
540
|
+
# @return [String] Pre-signed download URL.
|
|
541
|
+
# @raise [Daytona::Sdk::Error] if the signing key cannot be fetched.
|
|
542
|
+
#
|
|
543
|
+
# @example
|
|
544
|
+
# url = sandbox.download_url('/home/user/report.pdf')
|
|
545
|
+
# # curl "$url" -o report.pdf
|
|
546
|
+
def download_url(path, ttl_seconds: nil)
|
|
547
|
+
Utils::FileUrlSigning.build_signed_file_url(
|
|
548
|
+
@toolbox_proxy_url,
|
|
549
|
+
id,
|
|
550
|
+
'/files/download',
|
|
551
|
+
'GET',
|
|
552
|
+
path,
|
|
553
|
+
ensure_signing_key,
|
|
554
|
+
ttl_seconds
|
|
555
|
+
)
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
# Creates a pre-signed URL for uploading a file to the Sandbox.
|
|
559
|
+
#
|
|
560
|
+
# Send a POST request with the file as multipart/form-data. The URL works with any
|
|
561
|
+
# HTTP client without auth headers. The signing key is cached locally for up to
|
|
562
|
+
# 15 seconds; if the key was rotated from another client, URLs may be rejected
|
|
563
|
+
# until the cache refreshes.
|
|
564
|
+
#
|
|
565
|
+
# @param path [String] Destination path for the uploaded file in the Sandbox.
|
|
566
|
+
# @param ttl_seconds [Integer, nil] How long the URL stays valid, in seconds.
|
|
567
|
+
# Defaults to 3600. Zero or negative means the URL never expires.
|
|
568
|
+
# @return [String] Pre-signed upload URL.
|
|
569
|
+
# @raise [Daytona::Sdk::Error] if the signing key cannot be fetched.
|
|
570
|
+
#
|
|
571
|
+
# @example
|
|
572
|
+
# url = sandbox.upload_url('/home/user/data.bin')
|
|
573
|
+
# # curl -X POST -F "file=@local.bin" "$url"
|
|
574
|
+
def upload_url(path, ttl_seconds: nil)
|
|
575
|
+
Utils::FileUrlSigning.build_signed_file_url(
|
|
576
|
+
@toolbox_proxy_url,
|
|
577
|
+
id,
|
|
578
|
+
'/files/upload-v2',
|
|
579
|
+
'POST',
|
|
580
|
+
path,
|
|
581
|
+
ensure_signing_key,
|
|
582
|
+
ttl_seconds
|
|
583
|
+
)
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
# Rotates the sandbox signing key, invalidating all previously signed URLs.
|
|
587
|
+
#
|
|
588
|
+
# @return [void]
|
|
589
|
+
# @raise [DaytonaApiClient::ApiError] if the signing key rotation request fails.
|
|
590
|
+
#
|
|
591
|
+
# @example
|
|
592
|
+
# sandbox.rotate_signing_key
|
|
593
|
+
# puts 'All previously signed URLs are now invalid.'
|
|
594
|
+
def rotate_signing_key
|
|
595
|
+
@signing_key = @sandbox_api.rotate_signing_key(id)
|
|
596
|
+
@signing_key_fetched_at = Time.now.to_f
|
|
597
|
+
nil
|
|
598
|
+
end
|
|
599
|
+
|
|
527
600
|
# Creates a signed preview URL for the sandbox at the specified port.
|
|
528
601
|
#
|
|
529
602
|
# @param port [Integer] The port to open the preview link on
|
|
@@ -570,8 +643,8 @@ module Daytona
|
|
|
570
643
|
def refresh_activity
|
|
571
644
|
sandbox_api.update_last_activity(id)
|
|
572
645
|
nil
|
|
573
|
-
rescue
|
|
574
|
-
raise Sdk
|
|
646
|
+
rescue *Sdk::API_ERROR_CLASSES => e
|
|
647
|
+
raise Sdk.wrap_error(e, 'Failed to refresh sandbox activity')
|
|
575
648
|
end
|
|
576
649
|
|
|
577
650
|
# Revokes an SSH access token for the sandbox.
|
|
@@ -619,8 +692,8 @@ module Daytona
|
|
|
619
692
|
error_states: [DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED]
|
|
620
693
|
)
|
|
621
694
|
end
|
|
622
|
-
rescue
|
|
623
|
-
raise Sdk
|
|
695
|
+
rescue *Sdk::API_ERROR_CLASSES => e
|
|
696
|
+
raise Sdk.wrap_error(e, 'Failed to recover sandbox')
|
|
624
697
|
end
|
|
625
698
|
|
|
626
699
|
# Stops the Sandbox and waits for it to be stopped.
|
|
@@ -841,7 +914,8 @@ module Daytona
|
|
|
841
914
|
:update_network_settings, :update_secrets, :update_env,
|
|
842
915
|
:create_ssh_access, :delete, :get_user_home_dir, :get_work_dir, :get_metrics, :get_metrics_latest,
|
|
843
916
|
:labels=,
|
|
844
|
-
:preview_url, :
|
|
917
|
+
:preview_url, :download_url, :upload_url, :rotate_signing_key,
|
|
918
|
+
:create_signed_preview_url, :expire_signed_preview_url,
|
|
845
919
|
:refresh, :refresh_activity, :revoke_ssh_access, :start, :recover, :stop,
|
|
846
920
|
:create_lsp_server, :validate_ssh_access, :wait_for_sandbox_start,
|
|
847
921
|
:wait_for_sandbox_stop, :resize, :wait_for_resize_complete,
|
|
@@ -964,6 +1038,16 @@ module Daytona
|
|
|
964
1038
|
@toolbox_api_config.base_path = uri.path.empty? ? '/' : uri.path
|
|
965
1039
|
end
|
|
966
1040
|
|
|
1041
|
+
def ensure_signing_key
|
|
1042
|
+
key = @signing_key
|
|
1043
|
+
if key.nil? || (Time.now.to_f - @signing_key_fetched_at) > 15
|
|
1044
|
+
key = @sandbox_api.get_sandbox_signing_key(id)
|
|
1045
|
+
@signing_key = key
|
|
1046
|
+
@signing_key_fetched_at = Time.now.to_f
|
|
1047
|
+
end
|
|
1048
|
+
key
|
|
1049
|
+
end
|
|
1050
|
+
|
|
967
1051
|
# @params sandbox_dto [DaytonaApiClient::Sandbox, DaytonaApiClient::SandboxListItem]
|
|
968
1052
|
# @return [void]
|
|
969
1053
|
def process_response(sandbox_dto) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Copyright Daytona Platforms Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
# frozen_string_literal: true
|
|
5
|
+
|
|
6
|
+
# =============================================================================
|
|
7
|
+
# Backward-compatibility aliases. New functionality belongs in errors.rb, not
|
|
8
|
+
# here. Every name below exists solely so pre-typed-error-model user code
|
|
9
|
+
# keeps working, and this file can be removed as a whole in a future major
|
|
10
|
+
# release.
|
|
11
|
+
# =============================================================================
|
|
12
|
+
module Daytona
|
|
13
|
+
module Sdk
|
|
14
|
+
# @deprecated Use {BadRequestError} instead. Kept as an alias so existing
|
|
15
|
+
# `rescue Daytona::Sdk::ValidationError` blocks keep working.
|
|
16
|
+
ValidationError = BadRequestError
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# Copyright Daytona Platforms Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
# frozen_string_literal: true
|
|
5
|
+
|
|
6
|
+
require 'json'
|
|
7
|
+
|
|
8
|
+
module Daytona
|
|
9
|
+
module Sdk # rubocop:disable Metrics/ModuleLength
|
|
10
|
+
# ApiError classes raised by the generated OpenAPI clients. Kept here so
|
|
11
|
+
# the error module can resolve them without depending on `sdk.rb`.
|
|
12
|
+
API_ERROR_CLASSES = [DaytonaApiClient::ApiError, DaytonaToolboxApiClient::ApiError].freeze
|
|
13
|
+
|
|
14
|
+
# Wire-format `source` values set by the translation layer when a
|
|
15
|
+
# Daytona service stamps them on the wire envelope. `nil` source means
|
|
16
|
+
# the response did not carry a structured envelope (treat as opaque).
|
|
17
|
+
SOURCE_API = 'DAYTONA_API'
|
|
18
|
+
SOURCE_DAEMON = 'DAYTONA_DAEMON'
|
|
19
|
+
SOURCE_PROXY = 'DAYTONA_PROXY'
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------
|
|
22
|
+
# Base
|
|
23
|
+
# ---------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
# Base class for every error raised by the Daytona SDK.
|
|
26
|
+
#
|
|
27
|
+
# @example Catching any SDK error
|
|
28
|
+
# begin
|
|
29
|
+
# daytona.create(params)
|
|
30
|
+
# rescue Daytona::Sdk::Error => e
|
|
31
|
+
# puts e.status_code, e.code, e.source
|
|
32
|
+
# end
|
|
33
|
+
class Error < StandardError
|
|
34
|
+
def initialize(message = nil, status_code: nil, code: nil, source: nil, headers: nil)
|
|
35
|
+
super(message)
|
|
36
|
+
@status_code = status_code
|
|
37
|
+
@code = code
|
|
38
|
+
@source = source
|
|
39
|
+
@headers = headers
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def status_code = @status_code || metadata_from_cause[:status_code]
|
|
43
|
+
|
|
44
|
+
def code = @code || metadata_from_cause[:code]
|
|
45
|
+
|
|
46
|
+
def headers = @headers || metadata_from_cause[:headers] || {}
|
|
47
|
+
|
|
48
|
+
# Returns the originating service, or `nil` if unknown. Falls back to
|
|
49
|
+
# metadata carried by `cause` so a re-raised ApiError keeps its
|
|
50
|
+
# server-stamped source.
|
|
51
|
+
def source = @source || metadata_from_cause[:source]
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def metadata_from_cause
|
|
56
|
+
@metadata_from_cause ||= Sdk.api_error_details(cause)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------
|
|
61
|
+
# HTTP status-class errors
|
|
62
|
+
# ---------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
# HTTP 400 — request was rejected as malformed or invalid.
|
|
65
|
+
class BadRequestError < Error; end
|
|
66
|
+
|
|
67
|
+
# HTTP 401 — authentication failed or credentials missing.
|
|
68
|
+
class AuthenticationError < Error; end
|
|
69
|
+
|
|
70
|
+
# HTTP 403 — caller is authenticated but not allowed.
|
|
71
|
+
class ForbiddenError < Error; end
|
|
72
|
+
|
|
73
|
+
# HTTP 404 — target resource does not exist.
|
|
74
|
+
class NotFoundError < Error; end
|
|
75
|
+
|
|
76
|
+
# HTTP 408 / 504 / client-side timeouts.
|
|
77
|
+
class TimeoutError < Error; end
|
|
78
|
+
|
|
79
|
+
# HTTP 409 — request conflicts with current resource state.
|
|
80
|
+
class ConflictError < Error; end
|
|
81
|
+
|
|
82
|
+
# HTTP 410 — resource is permanently gone.
|
|
83
|
+
class GoneError < Error; end
|
|
84
|
+
|
|
85
|
+
# HTTP 422 — request is well-formed but semantically invalid.
|
|
86
|
+
class UnprocessableEntityError < Error; end
|
|
87
|
+
|
|
88
|
+
# HTTP 429 — rate limit was exceeded.
|
|
89
|
+
class RateLimitError < Error; end
|
|
90
|
+
|
|
91
|
+
# Generic 5xx fallback when a more specific class doesn't apply.
|
|
92
|
+
class ServerError < Error; end
|
|
93
|
+
|
|
94
|
+
# HTTP 500 — server-side bug or unhandled condition.
|
|
95
|
+
class InternalServerError < ServerError; end
|
|
96
|
+
|
|
97
|
+
# HTTP 502 — an upstream dependency rejected or dropped the request.
|
|
98
|
+
class BadGatewayError < ServerError; end
|
|
99
|
+
|
|
100
|
+
# HTTP 503 — the service is temporarily refusing traffic.
|
|
101
|
+
class ServiceUnavailableError < ServerError; end
|
|
102
|
+
|
|
103
|
+
# Transport-level failure (no HTTP response received).
|
|
104
|
+
class ConnectionError < Error; end
|
|
105
|
+
|
|
106
|
+
# Transport-level timeout. Subclass of ConnectionError so callers that
|
|
107
|
+
# catch the broader connection-failure category also match.
|
|
108
|
+
class ConnectionTimeoutError < ConnectionError; end
|
|
109
|
+
|
|
110
|
+
# ---------------------------------------------------------------------
|
|
111
|
+
# Domain subclasses — each inherits from the HTTP-status parent that
|
|
112
|
+
# matches its server-side status code.
|
|
113
|
+
# ---------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
# Daemon: git
|
|
116
|
+
class GitAuthFailedError < AuthenticationError; end
|
|
117
|
+
class GitRepoNotFoundError < NotFoundError; end
|
|
118
|
+
class GitBranchNotFoundError < NotFoundError; end
|
|
119
|
+
class GitBranchExistsError < ConflictError; end
|
|
120
|
+
class GitPushRejectedError < ConflictError; end
|
|
121
|
+
class GitDirtyWorktreeError < ConflictError; end
|
|
122
|
+
class GitMergeConflictError < ConflictError; end
|
|
123
|
+
|
|
124
|
+
# Daemon: filesystem
|
|
125
|
+
class FileNotFoundError < NotFoundError; end
|
|
126
|
+
class FileAccessDeniedError < ForbiddenError; end
|
|
127
|
+
|
|
128
|
+
# Daemon: LSP
|
|
129
|
+
class LspServerNotInitializedError < BadRequestError; end
|
|
130
|
+
|
|
131
|
+
# Daemon: process / session
|
|
132
|
+
class ProcessExecutionTimeoutError < TimeoutError; end
|
|
133
|
+
class ProcessNotFoundError < NotFoundError; end
|
|
134
|
+
class SessionEndedError < GoneError; end
|
|
135
|
+
class CommandAlreadyCompletedError < GoneError; end
|
|
136
|
+
|
|
137
|
+
# Daemon: computer-use
|
|
138
|
+
class A11yUnavailableError < ServiceUnavailableError; end
|
|
139
|
+
class RecordingStillActiveError < ConflictError; end
|
|
140
|
+
class RecordingFfmpegNotFoundError < ServiceUnavailableError; end
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------
|
|
143
|
+
# Routing tables
|
|
144
|
+
# ---------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
STATUS_CODE_TO_ERROR = {
|
|
147
|
+
400 => BadRequestError,
|
|
148
|
+
401 => AuthenticationError,
|
|
149
|
+
403 => ForbiddenError,
|
|
150
|
+
404 => NotFoundError,
|
|
151
|
+
408 => TimeoutError,
|
|
152
|
+
409 => ConflictError,
|
|
153
|
+
410 => GoneError,
|
|
154
|
+
422 => UnprocessableEntityError,
|
|
155
|
+
429 => RateLimitError,
|
|
156
|
+
500 => InternalServerError,
|
|
157
|
+
502 => BadGatewayError,
|
|
158
|
+
503 => ServiceUnavailableError,
|
|
159
|
+
504 => TimeoutError
|
|
160
|
+
}.freeze
|
|
161
|
+
|
|
162
|
+
# (source, code) tuple → exception class. Resolved BEFORE the status
|
|
163
|
+
# code, so a server-stamped domain code always wins over the generic
|
|
164
|
+
# status class.
|
|
165
|
+
CODE_TO_ERROR = {
|
|
166
|
+
# Daemon: git
|
|
167
|
+
[SOURCE_DAEMON, 'GIT_AUTH_FAILED'] => GitAuthFailedError,
|
|
168
|
+
[SOURCE_DAEMON, 'GIT_REPO_NOT_FOUND'] => GitRepoNotFoundError,
|
|
169
|
+
[SOURCE_DAEMON, 'GIT_BRANCH_NOT_FOUND'] => GitBranchNotFoundError,
|
|
170
|
+
[SOURCE_DAEMON, 'GIT_BRANCH_EXISTS'] => GitBranchExistsError,
|
|
171
|
+
[SOURCE_DAEMON, 'GIT_PUSH_REJECTED'] => GitPushRejectedError,
|
|
172
|
+
[SOURCE_DAEMON, 'GIT_DIRTY_WORKTREE'] => GitDirtyWorktreeError,
|
|
173
|
+
[SOURCE_DAEMON, 'GIT_MERGE_CONFLICT'] => GitMergeConflictError,
|
|
174
|
+
|
|
175
|
+
# Daemon: filesystem
|
|
176
|
+
[SOURCE_DAEMON, 'FILE_NOT_FOUND'] => FileNotFoundError,
|
|
177
|
+
[SOURCE_DAEMON, 'FILE_ACCESS_DENIED'] => FileAccessDeniedError,
|
|
178
|
+
|
|
179
|
+
# Daemon: LSP
|
|
180
|
+
[SOURCE_DAEMON, 'LSP_SERVER_NOT_INITIALIZED'] => LspServerNotInitializedError,
|
|
181
|
+
|
|
182
|
+
# Daemon: process / session
|
|
183
|
+
[SOURCE_DAEMON, 'PROCESS_EXECUTION_TIMEOUT'] => ProcessExecutionTimeoutError,
|
|
184
|
+
[SOURCE_DAEMON, 'PROCESS_NOT_FOUND'] => ProcessNotFoundError,
|
|
185
|
+
[SOURCE_DAEMON, 'SESSION_ENDED'] => SessionEndedError,
|
|
186
|
+
[SOURCE_DAEMON, 'COMMAND_ALREADY_COMPLETED'] => CommandAlreadyCompletedError,
|
|
187
|
+
|
|
188
|
+
# Daemon: computer-use
|
|
189
|
+
[SOURCE_DAEMON, 'A11Y_UNAVAILABLE'] => A11yUnavailableError,
|
|
190
|
+
[SOURCE_DAEMON, 'RECORDING_STILL_ACTIVE'] => RecordingStillActiveError,
|
|
191
|
+
[SOURCE_DAEMON, 'RECORDING_FFMPEG_NOT_FOUND'] => RecordingFfmpegNotFoundError
|
|
192
|
+
}.freeze
|
|
193
|
+
|
|
194
|
+
# ---------------------------------------------------------------------
|
|
195
|
+
# Public translation helpers (module-level functions on Daytona::Sdk)
|
|
196
|
+
# ---------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
# Translate an OpenAPI-client error into the most specific Daytona SDK
|
|
199
|
+
# exception. Accepts an optional `prefix` that's prepended to the
|
|
200
|
+
# message for context (e.g. "Failed to create sandbox"). When `error`
|
|
201
|
+
# is already an `Sdk::Error` (e.g. raised by the streaming transfer
|
|
202
|
+
# helpers for cancel/timeout), its class is preserved and only the
|
|
203
|
+
# message is prefixed.
|
|
204
|
+
def self.wrap_error(error, prefix = nil)
|
|
205
|
+
if error.is_a?(Error)
|
|
206
|
+
message = prefix ? "#{prefix}: #{error.message}" : error.message
|
|
207
|
+
return error.class.new(message, status_code: error.status_code, code: error.code,
|
|
208
|
+
source: error.source, headers: error.headers)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
details = api_error_details(error)
|
|
212
|
+
base_message = parsed_message(error) || error.message
|
|
213
|
+
message = prefix ? "#{prefix}: #{base_message}" : base_message
|
|
214
|
+
error_class_for(details).new(message, **details.slice(:status_code, :code, :source, :headers))
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Extract status code, code, source and headers from a raised OpenAPI
|
|
218
|
+
# error. Returns an empty hash when the error is not one of the
|
|
219
|
+
# generated client types.
|
|
220
|
+
def self.api_error_details(error)
|
|
221
|
+
return {} unless API_ERROR_CLASSES.any? { |c| error.is_a?(c) }
|
|
222
|
+
|
|
223
|
+
data = parse_error_body(error.respond_to?(:response_body) ? error.response_body : nil)
|
|
224
|
+
{
|
|
225
|
+
status_code: error.respond_to?(:code) ? error.code : nil,
|
|
226
|
+
code: data[:code],
|
|
227
|
+
source: data[:source],
|
|
228
|
+
headers: error.respond_to?(:response_headers) ? error.response_headers : nil
|
|
229
|
+
}
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Choose the exception class for a parsed error: (source, code) match
|
|
233
|
+
# wins, then HTTP status code, then the base Error.
|
|
234
|
+
def self.error_class_for(details)
|
|
235
|
+
code = details[:code]
|
|
236
|
+
source = details[:source]
|
|
237
|
+
if code && source
|
|
238
|
+
cls = CODE_TO_ERROR[[source, code]]
|
|
239
|
+
return cls if cls
|
|
240
|
+
end
|
|
241
|
+
status_code = details[:status_code]
|
|
242
|
+
exact_status_error = STATUS_CODE_TO_ERROR[status_code]
|
|
243
|
+
return exact_status_error if exact_status_error
|
|
244
|
+
|
|
245
|
+
return ServerError if status_code.is_a?(Integer) && status_code >= 500
|
|
246
|
+
|
|
247
|
+
Error
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def self.parse_error_body(response_body)
|
|
251
|
+
return {} if response_body.nil? || response_body.empty?
|
|
252
|
+
|
|
253
|
+
data = JSON.parse(response_body)
|
|
254
|
+
return {} unless data.is_a?(Hash)
|
|
255
|
+
|
|
256
|
+
{
|
|
257
|
+
message: string_or_nil(data['message']) || string_or_nil(data['error']),
|
|
258
|
+
code: string_or_nil(data['code'] || data['error_code']),
|
|
259
|
+
source: string_or_nil(data['source'])
|
|
260
|
+
}
|
|
261
|
+
rescue JSON::ParserError
|
|
262
|
+
{}
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# @api private
|
|
266
|
+
def self.parsed_message(error)
|
|
267
|
+
return nil unless API_ERROR_CLASSES.any? { |c| error.is_a?(c) }
|
|
268
|
+
return nil unless error.respond_to?(:response_body)
|
|
269
|
+
|
|
270
|
+
parse_error_body(error.response_body)[:message]
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def self.string_or_nil(value)
|
|
274
|
+
value.is_a?(String) && !value.empty? ? value : nil
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
private_class_method :error_class_for, :parse_error_body, :parsed_message, :string_or_nil
|
|
278
|
+
end
|
|
279
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Copyright Daytona Platforms Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
# frozen_string_literal: true
|
|
5
|
+
|
|
6
|
+
# Patches the OpenAPI-generated `download_file` and `call_api` helpers in both
|
|
7
|
+
# API clients so that streaming responses (return_type == 'File') do not lose
|
|
8
|
+
# the response body when the server returns an error status.
|
|
9
|
+
#
|
|
10
|
+
# Background:
|
|
11
|
+
# For File downloads the generated `download_file` attaches Typhoeus
|
|
12
|
+
# `on_body` callbacks to stream the response into a Tempfile. The presence
|
|
13
|
+
# of `on_body` prevents Typhoeus from populating `response.body`, which
|
|
14
|
+
# means the daemon's structured error envelope (code/source/message) is
|
|
15
|
+
# discarded when the response is an error. The generated `call_api` then
|
|
16
|
+
# raises `ApiError` with an empty `response_body`.
|
|
17
|
+
#
|
|
18
|
+
# Fix:
|
|
19
|
+
# * In `download_file`, defer tempfile creation until `on_headers` and
|
|
20
|
+
# inspect the status code. For 2xx responses, behave exactly like the
|
|
21
|
+
# upstream code. For non-2xx responses, accumulate the body into a
|
|
22
|
+
# buffer stored as an instance variable on the ApiClient.
|
|
23
|
+
# * In `call_api`, rescue the `ApiError`, and if our buffer is populated,
|
|
24
|
+
# re-raise with `response_body` set to the buffered envelope.
|
|
25
|
+
|
|
26
|
+
require 'tempfile'
|
|
27
|
+
|
|
28
|
+
module Daytona
|
|
29
|
+
module Sdk
|
|
30
|
+
# rubocop:disable Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
31
|
+
module FileDownloadPatch
|
|
32
|
+
def self.apply!(api_client_class, api_error_class)
|
|
33
|
+
return if api_client_class.method_defined?(:_daytona_orig_call_api)
|
|
34
|
+
|
|
35
|
+
api_client_class.class_eval do
|
|
36
|
+
define_method(:_daytona_error_body_key) do
|
|
37
|
+
@daytona_error_body_key ||= :"daytona_file_download_error_body_#{object_id}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
define_method(:download_file) do |request, &block|
|
|
41
|
+
tempfile = nil
|
|
42
|
+
encoding = nil
|
|
43
|
+
stream_to_tempfile = false
|
|
44
|
+
error_body = String.new.b
|
|
45
|
+
Thread.current.thread_variable_set(_daytona_error_body_key, nil)
|
|
46
|
+
|
|
47
|
+
request.on_headers do |response|
|
|
48
|
+
stream_to_tempfile = response.code && response.code >= 200 && response.code < 300
|
|
49
|
+
next unless stream_to_tempfile
|
|
50
|
+
|
|
51
|
+
content_disposition = response.headers['Content-Disposition']
|
|
52
|
+
if content_disposition && content_disposition =~ /filename=/i
|
|
53
|
+
filename = content_disposition[/filename=['"]?([^'"\s]+)['"]?/, 1]
|
|
54
|
+
prefix = sanitize_filename(filename)
|
|
55
|
+
else
|
|
56
|
+
prefix = 'download-'
|
|
57
|
+
end
|
|
58
|
+
prefix += '-' unless prefix.end_with?('-')
|
|
59
|
+
encoding = response.body.encoding
|
|
60
|
+
tempfile = Tempfile.open(prefix, @config.temp_folder_path, encoding: encoding)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
request.on_body do |chunk|
|
|
64
|
+
if stream_to_tempfile
|
|
65
|
+
chunk.force_encoding(encoding)
|
|
66
|
+
tempfile.write(chunk)
|
|
67
|
+
else
|
|
68
|
+
error_body << chunk.b
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
request.on_complete do
|
|
73
|
+
if stream_to_tempfile
|
|
74
|
+
if tempfile.nil?
|
|
75
|
+
raise api_error_class,
|
|
76
|
+
"Failed to create the tempfile based on the HTTP response from the server: #{request.inspect}"
|
|
77
|
+
end
|
|
78
|
+
tempfile.close
|
|
79
|
+
@config.logger.info(
|
|
80
|
+
"Temp file written to #{tempfile.path}, please copy the file to a proper folder " \
|
|
81
|
+
"with e.g. `FileUtils.cp(tempfile.path, '/new/file/path')` otherwise the temp file " \
|
|
82
|
+
'will be deleted automatically with GC. It\'s also recommended to delete the temp file ' \
|
|
83
|
+
'explicitly with `tempfile.delete`'
|
|
84
|
+
)
|
|
85
|
+
block&.call(tempfile)
|
|
86
|
+
else
|
|
87
|
+
Thread.current.thread_variable_set(_daytona_error_body_key, error_body) unless error_body.empty?
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
alias_method :_daytona_orig_call_api, :call_api
|
|
93
|
+
|
|
94
|
+
define_method(:call_api) do |http_method, path, opts = {}|
|
|
95
|
+
_daytona_orig_call_api(http_method, path, opts)
|
|
96
|
+
rescue api_error_class => e
|
|
97
|
+
error_body = Thread.current.thread_variable_get(_daytona_error_body_key)
|
|
98
|
+
if opts[:return_type] == 'File' && error_body && !error_body.empty?
|
|
99
|
+
new_err = api_error_class.new(
|
|
100
|
+
code: e.code,
|
|
101
|
+
response_headers: e.response_headers,
|
|
102
|
+
response_body: error_body
|
|
103
|
+
)
|
|
104
|
+
raise new_err, e.message
|
|
105
|
+
end
|
|
106
|
+
raise
|
|
107
|
+
ensure
|
|
108
|
+
Thread.current.thread_variable_set(_daytona_error_body_key, nil)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private :_daytona_error_body_key
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
# rubocop:enable Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
if Object.const_defined?(:DaytonaApiClient) &&
|
|
120
|
+
DaytonaApiClient.const_defined?(:ApiClient) &&
|
|
121
|
+
DaytonaApiClient.const_defined?(:ApiError)
|
|
122
|
+
Daytona::Sdk::FileDownloadPatch.apply!(DaytonaApiClient::ApiClient, DaytonaApiClient::ApiError)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
if Object.const_defined?(:DaytonaToolboxApiClient) &&
|
|
126
|
+
DaytonaToolboxApiClient.const_defined?(:ApiClient) &&
|
|
127
|
+
DaytonaToolboxApiClient.const_defined?(:ApiError)
|
|
128
|
+
Daytona::Sdk::FileDownloadPatch.apply!(
|
|
129
|
+
DaytonaToolboxApiClient::ApiClient, DaytonaToolboxApiClient::ApiError
|
|
130
|
+
)
|
|
131
|
+
end
|
data/lib/daytona/sdk/version.rb
CHANGED
data/lib/daytona/sdk.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
# Copyright Daytona Platforms Inc.
|
|
4
4
|
# SPDX-License-Identifier: Apache-2.0
|
|
5
5
|
|
|
6
|
+
require 'json'
|
|
6
7
|
require 'logger'
|
|
7
8
|
|
|
8
9
|
require 'daytona_api_client'
|
|
@@ -12,6 +13,9 @@ require 'toml'
|
|
|
12
13
|
require 'websocket-client-simple'
|
|
13
14
|
|
|
14
15
|
require_relative 'sdk/version'
|
|
16
|
+
require_relative 'sdk/errors'
|
|
17
|
+
require_relative 'sdk/deprecated'
|
|
18
|
+
require_relative 'sdk/file_download_patch'
|
|
15
19
|
require_relative 'config'
|
|
16
20
|
require_relative 'otel'
|
|
17
21
|
require_relative 'common/charts'
|
|
@@ -47,15 +51,9 @@ require_relative 'process'
|
|
|
47
51
|
|
|
48
52
|
module Daytona
|
|
49
53
|
module Sdk
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
class ForbiddenError < Error; end
|
|
54
|
-
class NotFoundError < Error; end
|
|
55
|
-
class ConflictError < Error; end
|
|
56
|
-
class ValidationError < Error; end
|
|
57
|
-
class RateLimitError < Error; end
|
|
58
|
-
class ServerError < Error; end
|
|
54
|
+
# The error hierarchy and translation helpers live in `sdk/errors.rb`.
|
|
55
|
+
# This file just provides cross-cutting bits that need to be loaded
|
|
56
|
+
# alongside them.
|
|
59
57
|
|
|
60
58
|
def self.logger = @logger ||= Logger.new($stdout, level: Logger::INFO)
|
|
61
59
|
end
|
|
@@ -147,7 +147,8 @@ module Daytona
|
|
|
147
147
|
aws_access_key_id: push_access_creds.access_key,
|
|
148
148
|
aws_secret_access_key: push_access_creds.secret,
|
|
149
149
|
aws_session_token: push_access_creds.session_token,
|
|
150
|
-
bucket_name: push_access_creds.bucket
|
|
150
|
+
bucket_name: push_access_creds.bucket,
|
|
151
|
+
region: push_access_creds.region
|
|
151
152
|
)
|
|
152
153
|
|
|
153
154
|
image.context_list.map do |context|
|