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.
- checksums.yaml +7 -0
- data/.rubocop.yml +16 -0
- data/.ruby-version +1 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/README.md +39 -0
- data/Rakefile +12 -0
- data/lib/daytona/code_toolbox/sandbox_python_code_toolbox.rb +439 -0
- data/lib/daytona/code_toolbox/sandbox_ts_code_toolbox.rb +23 -0
- data/lib/daytona/common/charts.rb +298 -0
- data/lib/daytona/common/code_language.rb +11 -0
- data/lib/daytona/common/daytona.rb +206 -0
- data/lib/daytona/common/file_system.rb +23 -0
- data/lib/daytona/common/git.rb +16 -0
- data/lib/daytona/common/image.rb +493 -0
- data/lib/daytona/common/process.rb +141 -0
- data/lib/daytona/common/pty.rb +306 -0
- data/lib/daytona/common/resources.rb +31 -0
- data/lib/daytona/common/response.rb +28 -0
- data/lib/daytona/common/snapshot.rb +110 -0
- data/lib/daytona/computer_use.rb +549 -0
- data/lib/daytona/config.rb +53 -0
- data/lib/daytona/daytona.rb +278 -0
- data/lib/daytona/file_system.rb +359 -0
- data/lib/daytona/git.rb +287 -0
- data/lib/daytona/lsp_server.rb +130 -0
- data/lib/daytona/object_storage.rb +169 -0
- data/lib/daytona/process.rb +484 -0
- data/lib/daytona/sandbox.rb +376 -0
- data/lib/daytona/sdk/version.rb +7 -0
- data/lib/daytona/sdk.rb +45 -0
- data/lib/daytona/snapshot_service.rb +198 -0
- data/lib/daytona/util.rb +56 -0
- data/lib/daytona/volume.rb +43 -0
- data/lib/daytona/volume_service.rb +49 -0
- data/sig/daytona/sdk.rbs +6 -0
- metadata +149 -0
data/lib/daytona/git.rb
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Daytona
|
|
4
|
+
class Git
|
|
5
|
+
# @return [String] The Sandbox ID
|
|
6
|
+
attr_reader :sandbox_id
|
|
7
|
+
|
|
8
|
+
# @return [DaytonaApiClient::ToolboxApi] API client for Sandbox operations
|
|
9
|
+
attr_reader :toolbox_api
|
|
10
|
+
|
|
11
|
+
# Initializes a new Git handler instance.
|
|
12
|
+
#
|
|
13
|
+
# @param sandbox_id [String] The Sandbox ID.
|
|
14
|
+
# @param toolbox_api [DaytonaApiClient::ToolboxApi] API client for Sandbox operations.
|
|
15
|
+
def initialize(sandbox_id:, toolbox_api:)
|
|
16
|
+
@sandbox_id = sandbox_id
|
|
17
|
+
@toolbox_api = toolbox_api
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Stages the specified files for the next commit, similar to
|
|
21
|
+
# running 'git add' on the command line.
|
|
22
|
+
#
|
|
23
|
+
# @param path [String] Path to the Git repository root. Relative paths are resolved based on
|
|
24
|
+
# the sandbox working directory.
|
|
25
|
+
# @param files [Array<String>] List of file paths or directories to stage, relative to the repository root.
|
|
26
|
+
# @return [void]
|
|
27
|
+
# @raise [Daytona::Sdk::Error] if adding files fails
|
|
28
|
+
#
|
|
29
|
+
# @example
|
|
30
|
+
# # Stage a single file
|
|
31
|
+
# sandbox.git.add("workspace/repo", ["file.txt"])
|
|
32
|
+
#
|
|
33
|
+
# # Stage multiple files
|
|
34
|
+
# sandbox.git.add("workspace/repo", [
|
|
35
|
+
# "src/main.rb",
|
|
36
|
+
# "spec/main_spec.rb",
|
|
37
|
+
# "README.md"
|
|
38
|
+
# ])
|
|
39
|
+
def add(path, files)
|
|
40
|
+
toolbox_api.git_add_files(sandbox_id, DaytonaApiClient::GitAddRequest.new(path:, files:))
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
raise Sdk::Error, "Failed to add files: #{e.message}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Lists branches in the repository.
|
|
46
|
+
#
|
|
47
|
+
# @param path [String] Path to the Git repository root. Relative paths are resolved based on
|
|
48
|
+
# the sandbox working directory.
|
|
49
|
+
# @return [DaytonaApiClient::ListBranchResponse] List of branches in the repository.
|
|
50
|
+
# @raise [Daytona::Sdk::Error] if listing branches fails
|
|
51
|
+
#
|
|
52
|
+
# @example
|
|
53
|
+
# response = sandbox.git.branches("workspace/repo")
|
|
54
|
+
# puts "Branches: #{response.branches}"
|
|
55
|
+
def branches(path)
|
|
56
|
+
toolbox_api.git_list_branches(sandbox_id, path)
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
raise Sdk::Error, "Failed to list branches: #{e.message}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Clones a Git repository into the specified path. It supports
|
|
62
|
+
# cloning specific branches or commits, and can authenticate with the remote
|
|
63
|
+
# repository if credentials are provided.
|
|
64
|
+
#
|
|
65
|
+
# @param url [String] Repository URL to clone from.
|
|
66
|
+
# @param path [String] Path where the repository should be cloned. Relative paths are resolved
|
|
67
|
+
# based on the sandbox working directory.
|
|
68
|
+
# @param branch [String, nil] Specific branch to clone. If not specified,
|
|
69
|
+
# clones the default branch.
|
|
70
|
+
# @param commit_id [String, nil] Specific commit to clone. If specified,
|
|
71
|
+
# the repository will be left in a detached HEAD state at this commit.
|
|
72
|
+
# @param username [String, nil] Git username for authentication.
|
|
73
|
+
# @param password [String, nil] Git password or token for authentication.
|
|
74
|
+
# @return [void]
|
|
75
|
+
# @raise [Daytona::Sdk::Error] if cloning repository fails
|
|
76
|
+
#
|
|
77
|
+
# @example
|
|
78
|
+
# # Clone the default branch
|
|
79
|
+
# sandbox.git.clone(
|
|
80
|
+
# url: "https://github.com/user/repo.git",
|
|
81
|
+
# path: "workspace/repo"
|
|
82
|
+
# )
|
|
83
|
+
#
|
|
84
|
+
# # Clone a specific branch with authentication
|
|
85
|
+
# sandbox.git.clone(
|
|
86
|
+
# url: "https://github.com/user/private-repo.git",
|
|
87
|
+
# path: "workspace/private",
|
|
88
|
+
# branch: "develop",
|
|
89
|
+
# username: "user",
|
|
90
|
+
# password: "token"
|
|
91
|
+
# )
|
|
92
|
+
#
|
|
93
|
+
# # Clone a specific commit
|
|
94
|
+
# sandbox.git.clone(
|
|
95
|
+
# url: "https://github.com/user/repo.git",
|
|
96
|
+
# path: "workspace/repo-old",
|
|
97
|
+
# commit_id: "abc123"
|
|
98
|
+
# )
|
|
99
|
+
def clone(url:, path:, branch: nil, commit_id: nil, username: nil, password: nil) # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
|
|
100
|
+
toolbox_api.git_clone_repository(
|
|
101
|
+
sandbox_id,
|
|
102
|
+
DaytonaApiClient::GitCloneRequest.new(
|
|
103
|
+
url: url,
|
|
104
|
+
branch: branch,
|
|
105
|
+
path: path,
|
|
106
|
+
username: username,
|
|
107
|
+
password: password,
|
|
108
|
+
commit_id: commit_id
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
rescue StandardError => e
|
|
112
|
+
raise Sdk::Error, "Failed to clone repository: #{e.message}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Creates a new commit with the staged changes. Make sure to stage
|
|
116
|
+
# changes using the add() method before committing.
|
|
117
|
+
#
|
|
118
|
+
# @param path [String] Path to the Git repository root. Relative paths are resolved based on
|
|
119
|
+
# the sandbox working directory.
|
|
120
|
+
# @param message [String] Commit message describing the changes.
|
|
121
|
+
# @param author [String] Name of the commit author.
|
|
122
|
+
# @param email [String] Email address of the commit author.
|
|
123
|
+
# @param allow_empty [Boolean] Allow creating an empty commit when no changes are staged. Defaults to false.
|
|
124
|
+
# @return [GitCommitResponse] Response containing the commit SHA.
|
|
125
|
+
# @raise [Daytona::Sdk::Error] if committing changes fails
|
|
126
|
+
#
|
|
127
|
+
# @example
|
|
128
|
+
# # Stage and commit changes
|
|
129
|
+
# sandbox.git.add("workspace/repo", ["README.md"])
|
|
130
|
+
# commit_response = sandbox.git.commit(
|
|
131
|
+
# path: "workspace/repo",
|
|
132
|
+
# message: "Update documentation",
|
|
133
|
+
# author: "John Doe",
|
|
134
|
+
# email: "john@example.com",
|
|
135
|
+
# allow_empty: true
|
|
136
|
+
# )
|
|
137
|
+
# puts "Commit SHA: #{commit_response.sha}"
|
|
138
|
+
def commit(path:, message:, author:, email:, allow_empty: false)
|
|
139
|
+
response = toolbox_api.git_commit_changes(
|
|
140
|
+
sandbox_id,
|
|
141
|
+
DaytonaApiClient::GitCommitRequest.new(path:, message:, author:, email:, allow_empty:)
|
|
142
|
+
)
|
|
143
|
+
GitCommitResponse.new(sha: response.hash)
|
|
144
|
+
rescue StandardError => e
|
|
145
|
+
raise Sdk::Error, "Failed to commit changes: #{e.message}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Pushes all local commits on the current branch to the remote
|
|
149
|
+
# repository. If the remote repository requires authentication, provide
|
|
150
|
+
# username and password/token.
|
|
151
|
+
#
|
|
152
|
+
# @param path [String] Path to the Git repository root. Relative paths are resolved based on
|
|
153
|
+
# the sandbox working directory.
|
|
154
|
+
# @param username [String, nil] Git username for authentication.
|
|
155
|
+
# @param password [String, nil] Git password or token for authentication.
|
|
156
|
+
# @return [void]
|
|
157
|
+
# @raise [Daytona::Sdk::Error] if pushing changes fails
|
|
158
|
+
#
|
|
159
|
+
# @example
|
|
160
|
+
# # Push without authentication (for public repos or SSH)
|
|
161
|
+
# sandbox.git.push("workspace/repo")
|
|
162
|
+
#
|
|
163
|
+
# # Push with authentication
|
|
164
|
+
# sandbox.git.push(
|
|
165
|
+
# path: "workspace/repo",
|
|
166
|
+
# username: "user",
|
|
167
|
+
# password: "github_token"
|
|
168
|
+
# )
|
|
169
|
+
def push(path:, username: nil, password: nil)
|
|
170
|
+
toolbox_api.git_push_changes(
|
|
171
|
+
sandbox_id,
|
|
172
|
+
DaytonaApiClient::GitRepoRequest.new(path:, username:, password:)
|
|
173
|
+
)
|
|
174
|
+
rescue StandardError => e
|
|
175
|
+
raise Sdk::Error, "Failed to push changes: #{e.message}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Pulls changes from the remote repository. If the remote repository requires authentication,
|
|
179
|
+
# provide username and password/token.
|
|
180
|
+
#
|
|
181
|
+
# @param path [String] Path to the Git repository root. Relative paths are resolved based on
|
|
182
|
+
# the sandbox working directory.
|
|
183
|
+
# @param username [String, nil] Git username for authentication.
|
|
184
|
+
# @param password [String, nil] Git password or token for authentication.
|
|
185
|
+
# @return [void]
|
|
186
|
+
# @raise [Daytona::Sdk::Error] if pulling changes fails
|
|
187
|
+
#
|
|
188
|
+
# @example
|
|
189
|
+
# # Pull without authentication
|
|
190
|
+
# sandbox.git.pull("workspace/repo")
|
|
191
|
+
#
|
|
192
|
+
# # Pull with authentication
|
|
193
|
+
# sandbox.git.pull(
|
|
194
|
+
# path: "workspace/repo",
|
|
195
|
+
# username: "user",
|
|
196
|
+
# password: "github_token"
|
|
197
|
+
# )
|
|
198
|
+
#
|
|
199
|
+
def pull(path:, username: nil, password: nil)
|
|
200
|
+
toolbox_api.git_pull_changes(
|
|
201
|
+
sandbox_id,
|
|
202
|
+
DaytonaApiClient::GitRepoRequest.new(path:, username:, password:)
|
|
203
|
+
)
|
|
204
|
+
rescue StandardError => e
|
|
205
|
+
raise Sdk::Error, "Failed to pull changes: #{e.message}"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Gets the current Git repository status.
|
|
209
|
+
#
|
|
210
|
+
# @param path [String] Path to the Git repository root. Relative paths are resolved based on
|
|
211
|
+
# the sandbox working directory.
|
|
212
|
+
# @return [DaytonaApiClient::GitStatus] Repository status information including:
|
|
213
|
+
# @raise [Daytona::Sdk::Error] if getting status fails
|
|
214
|
+
#
|
|
215
|
+
# @example
|
|
216
|
+
# status = sandbox.git.status("workspace/repo")
|
|
217
|
+
# puts "On branch: #{status.current_branch}"
|
|
218
|
+
# puts "Commits ahead: #{status.ahead}"
|
|
219
|
+
# puts "Commits behind: #{status.behind}"
|
|
220
|
+
def status(path)
|
|
221
|
+
toolbox_api.git_get_status(sandbox_id, path)
|
|
222
|
+
rescue StandardError => e
|
|
223
|
+
raise Sdk::Error, "Failed to get status: #{e.message}"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Checkout branch in the repository.
|
|
227
|
+
#
|
|
228
|
+
# @param path [String] Path to the Git repository root. Relative paths are resolved based on
|
|
229
|
+
# the sandbox working directory.
|
|
230
|
+
# @param branch [String] Name of the branch to checkout
|
|
231
|
+
# @return [void]
|
|
232
|
+
# @raise [Daytona::Sdk::Error] if checking out branch fails
|
|
233
|
+
#
|
|
234
|
+
# @example
|
|
235
|
+
# # Checkout a branch
|
|
236
|
+
# sandbox.git.checkout_branch("workspace/repo", "feature-branch")
|
|
237
|
+
def checkout_branch(path, branch)
|
|
238
|
+
toolbox_api.git_checkout_branch(
|
|
239
|
+
sandbox_id,
|
|
240
|
+
DaytonaApiClient::GitCheckoutRequest.new(path:, branch:)
|
|
241
|
+
)
|
|
242
|
+
rescue StandardError => e
|
|
243
|
+
raise Sdk::Error, "Failed to checkout branch: #{e.message}"
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Create branch in the repository.
|
|
247
|
+
#
|
|
248
|
+
# @param path [String] Path to the Git repository root. Relative paths are resolved based on
|
|
249
|
+
# the sandbox working directory.
|
|
250
|
+
# @param name [String] Name of the new branch to create
|
|
251
|
+
# @return [void]
|
|
252
|
+
# @raise [Daytona::Sdk::Error] if creating branch fails
|
|
253
|
+
#
|
|
254
|
+
# @example
|
|
255
|
+
# # Create a new branch
|
|
256
|
+
# sandbox.git.create_branch("workspace/repo", "new-feature")
|
|
257
|
+
#
|
|
258
|
+
def create_branch(path, name)
|
|
259
|
+
toolbox_api.git_create_branch(
|
|
260
|
+
sandbox_id,
|
|
261
|
+
DaytonaApiClient::GitBranchRequest.new(path:, name:)
|
|
262
|
+
)
|
|
263
|
+
rescue StandardError => e
|
|
264
|
+
raise Sdk::Error, "Failed to create branch: #{e.message}"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Delete branch in the repository.
|
|
268
|
+
#
|
|
269
|
+
# @param path [String] Path to the Git repository root. Relative paths are resolved based on
|
|
270
|
+
# the sandbox working directory.
|
|
271
|
+
# @param name [String] Name of the branch to delete
|
|
272
|
+
# @return [void]
|
|
273
|
+
# @raise [Daytona::Sdk::Error] if deleting branch fails
|
|
274
|
+
#
|
|
275
|
+
# @example
|
|
276
|
+
# # Delete a branch
|
|
277
|
+
# sandbox.git.delete_branch("workspace/repo", "old-feature")
|
|
278
|
+
def delete_branch(path, name)
|
|
279
|
+
toolbox_api.git_delete_branch(
|
|
280
|
+
sandbox_id,
|
|
281
|
+
DaytonaApiClient::GitDeleteBranchRequest.new(path:, name:)
|
|
282
|
+
)
|
|
283
|
+
rescue StandardError => e
|
|
284
|
+
raise Sdk::Error, "Failed to delete branch: #{e.message}"
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Daytona
|
|
4
|
+
class LspServer
|
|
5
|
+
module Language
|
|
6
|
+
ALL = [
|
|
7
|
+
JAVASCRIPT = :javascript,
|
|
8
|
+
PYTHON = :python,
|
|
9
|
+
TYPESCRIPT = :typescript
|
|
10
|
+
|
|
11
|
+
].freeze
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Represents a zero-based position in a text document,
|
|
15
|
+
# specified by line number and character offset.
|
|
16
|
+
Position = Data.define(:line, :character)
|
|
17
|
+
|
|
18
|
+
# @return [Symbol]
|
|
19
|
+
attr_reader :language_id
|
|
20
|
+
|
|
21
|
+
# @return [String]
|
|
22
|
+
attr_reader :path_to_project
|
|
23
|
+
|
|
24
|
+
# @return [DaytonaApiClient::ToolboxApi]
|
|
25
|
+
attr_reader :toolbox_api
|
|
26
|
+
|
|
27
|
+
# @return [String]
|
|
28
|
+
attr_reader :sandbox_id
|
|
29
|
+
|
|
30
|
+
# @param language_id [Symbol]
|
|
31
|
+
# @param path_to_project [String]
|
|
32
|
+
# @param toolbox_api [DaytonaApiClient::ToolboxApi]
|
|
33
|
+
# @param sandbox_id [String]
|
|
34
|
+
def initialize(language_id:, path_to_project:, toolbox_api:, sandbox_id:)
|
|
35
|
+
@language_id = language_id
|
|
36
|
+
@path_to_project = path_to_project
|
|
37
|
+
@toolbox_api = toolbox_api
|
|
38
|
+
@sandbox_id = sandbox_id
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Gets completion suggestions at a position in a file
|
|
42
|
+
#
|
|
43
|
+
# @param path [String]
|
|
44
|
+
# @param position [Daytona::LspServer::Position]
|
|
45
|
+
# @return [DaytonaApiClient::CompletionList]
|
|
46
|
+
def completions(path:, position:)
|
|
47
|
+
toolbox_api.lsp_completions(
|
|
48
|
+
sandbox_id,
|
|
49
|
+
DaytonaApiClient::LspCompletionParams.new(
|
|
50
|
+
language_id:,
|
|
51
|
+
path_to_project:,
|
|
52
|
+
uri: uri(path),
|
|
53
|
+
position: DaytonaApiClient::Position.new(line: position.line, character: position.character)
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Notify the language server that a file has been closed.
|
|
59
|
+
# This method should be called when a file is closed in the editor to allow
|
|
60
|
+
# the language server to clean up any resources associated with that file.
|
|
61
|
+
#
|
|
62
|
+
# @param path [String]
|
|
63
|
+
# @return [void]
|
|
64
|
+
def did_close(path)
|
|
65
|
+
toolbox_api.lsp_did_close(
|
|
66
|
+
sandbox_id,
|
|
67
|
+
DaytonaApiClient::LspDocumentRequest.new(language_id:, path_to_project:, uri: uri(path))
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Notifies the language server that a file has been opened.
|
|
72
|
+
# This method should be called when a file is opened in the editor to enable
|
|
73
|
+
# language features like diagnostics and completions for that file. The server
|
|
74
|
+
# will begin tracking the file's contents and providing language features.
|
|
75
|
+
#
|
|
76
|
+
# @param path [String]
|
|
77
|
+
# @return [void]
|
|
78
|
+
def did_open(path)
|
|
79
|
+
toolbox_api.lsp_did_open(
|
|
80
|
+
sandbox_id,
|
|
81
|
+
DaytonaApiClient::LspDocumentRequest.new(language_id:, path_to_project:, uri: uri(path))
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Gets symbol information (functions, classes, variables, etc.) from a document.
|
|
86
|
+
#
|
|
87
|
+
# @param path [String]
|
|
88
|
+
# @return [Array<DaytonaApiClient::LspSymbol]
|
|
89
|
+
def document_symbols(path) = toolbox_api.lsp_document_symbols(sandbox_id, language_id, path_to_project, uri(path))
|
|
90
|
+
|
|
91
|
+
# Searches for symbols matching the query string across all files
|
|
92
|
+
# in the Sandbox.
|
|
93
|
+
#
|
|
94
|
+
# @param query [String]
|
|
95
|
+
# @return [Array<DaytonaApiClient::LspSymbol]
|
|
96
|
+
def sandbox_symbols(query) = toolbox_api.lsp_workspace_symbols(sandbox_id, language_id, path_to_project, query)
|
|
97
|
+
|
|
98
|
+
# Starts the language server.
|
|
99
|
+
# This method must be called before using any other LSP functionality.
|
|
100
|
+
# It initializes the language server for the specified language and project.
|
|
101
|
+
#
|
|
102
|
+
# @return [void]
|
|
103
|
+
def start
|
|
104
|
+
toolbox_api.lsp_start(
|
|
105
|
+
sandbox_id,
|
|
106
|
+
DaytonaApiClient::LspServerRequest.new(language_id:, path_to_project:)
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Stops the language server.
|
|
111
|
+
# This method should be called when the LSP server is no longer needed to
|
|
112
|
+
# free up system resources.
|
|
113
|
+
#
|
|
114
|
+
# @return [void]
|
|
115
|
+
def stop
|
|
116
|
+
toolbox_api.lsp_stop(
|
|
117
|
+
sandbox_id,
|
|
118
|
+
DaytonaApiClient::LspServerRequest.new(language_id:, path_to_project:)
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
# Convert path to file uri.
|
|
125
|
+
#
|
|
126
|
+
# @param path [String]
|
|
127
|
+
# @return [String]
|
|
128
|
+
def uri(path) = "file://#{path}"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'pathname'
|
|
6
|
+
require 'tempfile'
|
|
7
|
+
require 'zlib'
|
|
8
|
+
require 'aws-sdk-s3'
|
|
9
|
+
|
|
10
|
+
module Daytona
|
|
11
|
+
class ObjectStorage
|
|
12
|
+
# @return [String] The name of the S3 bucket used for object storage
|
|
13
|
+
attr_reader :bucket_name
|
|
14
|
+
|
|
15
|
+
# @return [Aws::S3::Client] The S3 client
|
|
16
|
+
attr_reader :s3_client
|
|
17
|
+
|
|
18
|
+
# Initialize ObjectStorage with S3-compatible credentials
|
|
19
|
+
#
|
|
20
|
+
# @param endpoint_url [String] The endpoint URL for the object storage service
|
|
21
|
+
# @param aws_access_key_id [String] The access key ID for the object storage service
|
|
22
|
+
# @param aws_secret_access_key [String] The secret access key for the object storage service
|
|
23
|
+
# @param aws_session_token [String] The session token for the object storage service
|
|
24
|
+
# @param bucket_name [String] The name of the bucket to use (defaults to "daytona-volume-builds")
|
|
25
|
+
# @param region [String] AWS region (defaults to us-east-1)
|
|
26
|
+
def initialize(endpoint_url:, aws_access_key_id:, aws_secret_access_key:, aws_session_token:, # rubocop:disable Metrics/ParameterLists
|
|
27
|
+
bucket_name: DEFAULT_BUCKET_NAME, region: DEFAULT_REGION)
|
|
28
|
+
@bucket_name = bucket_name
|
|
29
|
+
@s3_client = Aws::S3::Client.new(
|
|
30
|
+
region:,
|
|
31
|
+
endpoint: endpoint_url,
|
|
32
|
+
access_key_id: aws_access_key_id,
|
|
33
|
+
secret_access_key: aws_secret_access_key,
|
|
34
|
+
session_token: aws_session_token
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Uploads a file to the object storage service
|
|
39
|
+
#
|
|
40
|
+
# @param path [String] The path to the file to upload
|
|
41
|
+
# @param organization_id [String] The organization ID to use
|
|
42
|
+
# @param archive_base_path [String, nil] The base path to use for the archive
|
|
43
|
+
# @return [String] The hash of the uploaded file
|
|
44
|
+
# @raise [Errno::ENOENT] If the path does not exist
|
|
45
|
+
def upload(path, organization_id, archive_base_path = nil)
|
|
46
|
+
raise Errno::ENOENT, "Path does not exist: #{path}" unless File.exist?(path)
|
|
47
|
+
|
|
48
|
+
path_hash = compute_hash_for_path_md5(path, archive_base_path)
|
|
49
|
+
s3_key = "#{organization_id}/#{path_hash}/context.tar"
|
|
50
|
+
|
|
51
|
+
return path_hash if file_exists_in_s3(s3_key)
|
|
52
|
+
|
|
53
|
+
upload_as_tar(s3_key, path, archive_base_path)
|
|
54
|
+
|
|
55
|
+
path_hash
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Compute the base path for an archive. Returns normalized path without the root
|
|
59
|
+
# (drive letter or leading slash).
|
|
60
|
+
#
|
|
61
|
+
# @param path_str [String] The path to compute the base path for
|
|
62
|
+
# @return [String] The base path for the given path
|
|
63
|
+
def self.compute_archive_base_path(path_str)
|
|
64
|
+
normalized_path = File.basename(path_str)
|
|
65
|
+
|
|
66
|
+
# Remove drive letter for Windows paths (e.g., C:)
|
|
67
|
+
path_without_drive = normalized_path.gsub(/^[A-Za-z]:/, '')
|
|
68
|
+
|
|
69
|
+
# Remove leading separators (both / and \)
|
|
70
|
+
path_without_drive.gsub(%r{^[/\\]+}, '')
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# Computes the MD5 hash for a given path
|
|
76
|
+
#
|
|
77
|
+
# @param path_str [String] The path to compute the hash for
|
|
78
|
+
# @param archive_base_path [String, nil] The base path to use for the archive
|
|
79
|
+
# @return [String] The MD5 hash for the given path
|
|
80
|
+
def compute_hash_for_path_md5(path_str, archive_base_path = nil) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
81
|
+
md5_hasher = Digest::MD5.new
|
|
82
|
+
abs_path_str = File.expand_path(path_str)
|
|
83
|
+
|
|
84
|
+
archive_base_path = self.class.compute_archive_base_path(path_str) if archive_base_path.nil?
|
|
85
|
+
md5_hasher.update(archive_base_path)
|
|
86
|
+
|
|
87
|
+
if File.file?(abs_path_str)
|
|
88
|
+
File.open(abs_path_str, 'rb') do |f|
|
|
89
|
+
while (chunk = f.read(8192))
|
|
90
|
+
md5_hasher.update(chunk)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
else
|
|
94
|
+
Dir.glob(File.join(abs_path_str, '**', '*')).each do |file_path|
|
|
95
|
+
next unless File.file?(file_path)
|
|
96
|
+
|
|
97
|
+
rel_path = Pathname.new(file_path).relative_path_from(Pathname.new(abs_path_str)).to_s
|
|
98
|
+
|
|
99
|
+
md5_hasher.update(rel_path)
|
|
100
|
+
|
|
101
|
+
File.open(file_path, 'rb') do |f|
|
|
102
|
+
while (chunk = f.read(8192))
|
|
103
|
+
md5_hasher.update(chunk)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Handle empty directories
|
|
109
|
+
Dir
|
|
110
|
+
.glob(File.join(abs_path_str, '**', '*'))
|
|
111
|
+
.select { |path| File.directory?(path) && Dir.empty?(path) }
|
|
112
|
+
.each do |empty_dir|
|
|
113
|
+
rel_dir = Pathname.new(empty_dir).relative_path_from(Pathname.new(abs_path_str)).to_s
|
|
114
|
+
md5_hasher.update(rel_dir)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
md5_hasher.hexdigest
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Checks whether a specific object exists at the given path
|
|
122
|
+
#
|
|
123
|
+
# @param file_path [String] Full object path, e.g. "org/abcd123/context.tar"
|
|
124
|
+
# @return [Boolean] True if the object exists, False otherwise
|
|
125
|
+
def file_exists_in_s3(file_path)
|
|
126
|
+
s3_client.head_object(bucket: bucket_name, key: file_path)
|
|
127
|
+
true
|
|
128
|
+
rescue Aws::S3::Errors::NotFound
|
|
129
|
+
false
|
|
130
|
+
rescue StandardError => e
|
|
131
|
+
Sdk.logger.debug("Error checking file existence: #{e.message}")
|
|
132
|
+
false
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Uploads a file to the object storage service as a tar
|
|
136
|
+
#
|
|
137
|
+
# @param s3_key [String] The key to upload the file to
|
|
138
|
+
# @param source_path [String] The path to the file to upload
|
|
139
|
+
# @param archive_base_path [String, nil] The base path to use for the archive
|
|
140
|
+
def upload_as_tar(s3_key, source_path, archive_base_path = nil) # rubocop:disable Metrics/MethodLength
|
|
141
|
+
source_path = File.expand_path(source_path)
|
|
142
|
+
|
|
143
|
+
self.class.compute_archive_base_path(source_path) if archive_base_path.nil?
|
|
144
|
+
|
|
145
|
+
temp_file = Tempfile.new(['context', '.tar'])
|
|
146
|
+
|
|
147
|
+
begin
|
|
148
|
+
system('tar', '-cf', temp_file.path, '-C', File.dirname(source_path), File.basename(source_path))
|
|
149
|
+
|
|
150
|
+
File.open(temp_file.path, 'rb') do |file|
|
|
151
|
+
s3_client.put_object(
|
|
152
|
+
bucket: bucket_name,
|
|
153
|
+
key: s3_key,
|
|
154
|
+
body: file
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
ensure
|
|
158
|
+
temp_file.close
|
|
159
|
+
temp_file.unlink
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
DEFAULT_BUCKET_NAME = 'daytona-volume-builds'
|
|
164
|
+
private_constant :DEFAULT_BUCKET_NAME
|
|
165
|
+
|
|
166
|
+
DEFAULT_REGION = 'us-east-1'
|
|
167
|
+
private_constant :DEFAULT_REGION
|
|
168
|
+
end
|
|
169
|
+
end
|