nightona 0.191.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/.rspec +3 -0
- data/.rubocop.yml +22 -0
- data/.ruby-version +1 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE +190 -0
- data/README.md +184 -0
- data/Rakefile +12 -0
- data/lib/nightona/code_interpreter.rb +359 -0
- data/lib/nightona/common/charts.rb +124 -0
- data/lib/nightona/common/code_interpreter.rb +56 -0
- data/lib/nightona/common/code_language.rb +14 -0
- data/lib/nightona/common/file_system.rb +26 -0
- data/lib/nightona/common/git.rb +19 -0
- data/lib/nightona/common/image.rb +500 -0
- data/lib/nightona/common/nightona.rb +230 -0
- data/lib/nightona/common/process.rb +149 -0
- data/lib/nightona/common/pty.rb +309 -0
- data/lib/nightona/common/resources.rb +39 -0
- data/lib/nightona/common/response.rb +83 -0
- data/lib/nightona/common/snapshot.rb +124 -0
- data/lib/nightona/computer_use.rb +919 -0
- data/lib/nightona/config.rb +116 -0
- data/lib/nightona/file_system.rb +451 -0
- data/lib/nightona/file_transfer.rb +383 -0
- data/lib/nightona/git.rb +334 -0
- data/lib/nightona/lsp_server.rb +139 -0
- data/lib/nightona/nightona.rb +336 -0
- data/lib/nightona/object_storage.rb +172 -0
- data/lib/nightona/otel.rb +183 -0
- data/lib/nightona/process.rb +550 -0
- data/lib/nightona/sandbox.rb +751 -0
- data/lib/nightona/sdk/version.rb +10 -0
- data/lib/nightona/sdk.rb +56 -0
- data/lib/nightona/snapshot_service.rb +238 -0
- data/lib/nightona/util.rb +80 -0
- data/lib/nightona/volume.rb +46 -0
- data/lib/nightona/volume_service.rb +61 -0
- data/lib/nightona.rb +10 -0
- data/project.json +100 -0
- data/scripts/generate-docs.rb +402 -0
- data/sig/nightona/sdk.rbs +6 -0
- metadata +242 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
# Copyright Daytona Platforms Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
# frozen_string_literal: true
|
|
5
|
+
|
|
6
|
+
require 'json'
|
|
7
|
+
require 'uri'
|
|
8
|
+
|
|
9
|
+
module Nightona
|
|
10
|
+
class Nightona
|
|
11
|
+
include Instrumentation
|
|
12
|
+
|
|
13
|
+
# @return [Nightona::Config]
|
|
14
|
+
attr_reader :config
|
|
15
|
+
|
|
16
|
+
# @return [NightonaApiClient]
|
|
17
|
+
attr_reader :api_client
|
|
18
|
+
|
|
19
|
+
# @return [NightonaApiClient::SandboxApi]
|
|
20
|
+
attr_reader :sandbox_api
|
|
21
|
+
|
|
22
|
+
# @return [Nightona::VolumeService]
|
|
23
|
+
attr_reader :volume
|
|
24
|
+
|
|
25
|
+
# @return [NightonaApiClient::ObjectStorageApi]
|
|
26
|
+
attr_reader :object_storage_api
|
|
27
|
+
|
|
28
|
+
# @return [NightonaApiClient::SnapshotsApi]
|
|
29
|
+
attr_reader :snapshots_api
|
|
30
|
+
|
|
31
|
+
# @return [Nightona::SnapshotService]
|
|
32
|
+
attr_reader :snapshot
|
|
33
|
+
|
|
34
|
+
# @param config [Nightona::Config] Configuration options. Defaults to Nightona::Config.new
|
|
35
|
+
def initialize(config = Config.new)
|
|
36
|
+
@config = config
|
|
37
|
+
ensure_access_token_defined
|
|
38
|
+
|
|
39
|
+
otel_enabled = config.otel_enabled ||
|
|
40
|
+
config._experimental&.dig('otel_enabled') ||
|
|
41
|
+
config.read_env('NIGHTONA_OTEL_ENABLED') == 'true' ||
|
|
42
|
+
config.read_env('NIGHTONA_EXPERIMENTAL_OTEL_ENABLED') == 'true'
|
|
43
|
+
@otel_state = (::Nightona.init_otel(Sdk::VERSION) if otel_enabled)
|
|
44
|
+
|
|
45
|
+
@api_client = build_api_client
|
|
46
|
+
@sandbox_api = NightonaApiClient::SandboxApi.new(api_client)
|
|
47
|
+
@config_api = NightonaApiClient::ConfigApi.new(api_client)
|
|
48
|
+
@volume = VolumeService.new(NightonaApiClient::VolumesApi.new(api_client), otel_state:)
|
|
49
|
+
@object_storage_api = NightonaApiClient::ObjectStorageApi.new(api_client)
|
|
50
|
+
@snapshots_api = NightonaApiClient::SnapshotsApi.new(api_client)
|
|
51
|
+
@snapshot = SnapshotService.new(snapshots_api:, object_storage_api:, default_region_id: config.target,
|
|
52
|
+
otel_state:)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Shuts down OTel providers, flushing any pending telemetry data.
|
|
56
|
+
#
|
|
57
|
+
# @return [void]
|
|
58
|
+
def close
|
|
59
|
+
::Nightona.shutdown_otel(@otel_state)
|
|
60
|
+
@otel_state = nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Creates a sandbox with the specified parameters
|
|
64
|
+
#
|
|
65
|
+
# @param params [Nightona::CreateSandboxFromSnapshotParams, Nightona::CreateSandboxFromImageParams, Nil] Sandbox creation parameters
|
|
66
|
+
# @return [Nightona::Sandbox] The created sandbox
|
|
67
|
+
# @raise [Nightona::Sdk::Error] If auto_stop_interval or auto_archive_interval is negative
|
|
68
|
+
def create(params = nil, on_snapshot_create_logs: nil)
|
|
69
|
+
if params.nil?
|
|
70
|
+
params = CreateSandboxFromSnapshotParams.new(language: CodeLanguage::PYTHON)
|
|
71
|
+
elsif params.language.nil?
|
|
72
|
+
params.language = CodeLanguage::PYTHON
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
unless CodeLanguage::ALL.include?(params.language.to_s.to_sym)
|
|
76
|
+
raise ArgumentError,
|
|
77
|
+
"Invalid #{CODE_TOOLBOX_LANGUAGE_LABEL}: #{params.language}. Supported languages: #{CodeLanguage::ALL.join(', ')}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
_create(params, on_snapshot_create_logs:)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Deletes a Sandbox.
|
|
84
|
+
#
|
|
85
|
+
# @param sandbox [Nightona::Sandbox]
|
|
86
|
+
# @return [void]
|
|
87
|
+
def delete(sandbox) = sandbox.delete
|
|
88
|
+
|
|
89
|
+
# Gets a Sandbox by its ID.
|
|
90
|
+
#
|
|
91
|
+
# @param id [String]
|
|
92
|
+
# @return [Nightona::Sandbox]
|
|
93
|
+
def get(id)
|
|
94
|
+
sandbox_dto = sandbox_api.get_sandbox(id)
|
|
95
|
+
to_sandbox(sandbox_dto:)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Iterates over Sandboxes matching the given query.
|
|
99
|
+
#
|
|
100
|
+
# @param query [Nightona::ListSandboxesQuery, nil] Optional filters, sorting, and per-page size.
|
|
101
|
+
# @return [Enumerator<Nightona::Sandbox>]
|
|
102
|
+
# @raise [Nightona::Sdk::Error]
|
|
103
|
+
#
|
|
104
|
+
# @example
|
|
105
|
+
# nightona.list(Nightona::ListSandboxesQuery.new(labels: { 'env' => 'dev' })).each do |sandbox|
|
|
106
|
+
# puts sandbox.id
|
|
107
|
+
# end
|
|
108
|
+
def list(query = nil)
|
|
109
|
+
q = query || ListSandboxesQuery.new
|
|
110
|
+
|
|
111
|
+
Enumerator.new do |yielder|
|
|
112
|
+
cursor = nil
|
|
113
|
+
first_page = true
|
|
114
|
+
while first_page || cursor
|
|
115
|
+
first_page = false
|
|
116
|
+
response = fetch_sandbox_page(q, cursor)
|
|
117
|
+
response.items.each do |sandbox_dto|
|
|
118
|
+
yielder << to_sandbox(sandbox_dto: sandbox_dto)
|
|
119
|
+
end
|
|
120
|
+
cursor = response.next_cursor
|
|
121
|
+
break if cursor.nil? || (cursor.respond_to?(:empty?) && cursor.empty?)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Starts a Sandbox and waits for it to be ready.
|
|
127
|
+
#
|
|
128
|
+
# @param sandbox [Nightona::Sandbox]
|
|
129
|
+
# @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
|
|
130
|
+
# @return [void]
|
|
131
|
+
def start(sandbox, timeout = Sandbox::DEFAULT_TIMEOUT) = sandbox.start(timeout)
|
|
132
|
+
|
|
133
|
+
# Stops a Sandbox and waits for it to be stopped.
|
|
134
|
+
#
|
|
135
|
+
# @param sandbox [Nightona::Sandbox]
|
|
136
|
+
# @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
|
|
137
|
+
# @return [void]
|
|
138
|
+
def stop(sandbox, timeout = Sandbox::DEFAULT_TIMEOUT) = sandbox.stop(timeout)
|
|
139
|
+
|
|
140
|
+
instrument :create, :delete, :get, :start, :stop, component: 'Nightona'
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
# @return [Nightona::OtelState, nil]
|
|
145
|
+
attr_reader :otel_state
|
|
146
|
+
|
|
147
|
+
# Fetches a single page of sandboxes. Each call produces one OTel span
|
|
148
|
+
# ("Nightona.list_fetch_page") so that paginated iteration emits N spans
|
|
149
|
+
# for N pages.
|
|
150
|
+
#
|
|
151
|
+
# @param q [Nightona::ListSandboxesQuery]
|
|
152
|
+
# @param cursor [String, nil]
|
|
153
|
+
# @return [NightonaApiClient::ListSandboxesResponse]
|
|
154
|
+
def fetch_sandbox_page(q, cursor)
|
|
155
|
+
opts = {
|
|
156
|
+
cursor: cursor,
|
|
157
|
+
limit: q.limit,
|
|
158
|
+
id: q.id,
|
|
159
|
+
name: q.name,
|
|
160
|
+
labels: q.labels ? JSON.dump(q.labels) : nil,
|
|
161
|
+
states: q.states,
|
|
162
|
+
snapshots: q.snapshots,
|
|
163
|
+
region_ids: q.targets,
|
|
164
|
+
min_cpu: q.min_cpu,
|
|
165
|
+
max_cpu: q.max_cpu,
|
|
166
|
+
min_memory_gi_b: q.min_memory_gib,
|
|
167
|
+
max_memory_gi_b: q.max_memory_gib,
|
|
168
|
+
min_disk_gi_b: q.min_disk_gib,
|
|
169
|
+
max_disk_gi_b: q.max_disk_gib,
|
|
170
|
+
is_public: q.is_public,
|
|
171
|
+
is_recoverable: q.is_recoverable,
|
|
172
|
+
created_at_after: q.created_at_after,
|
|
173
|
+
created_at_before: q.created_at_before,
|
|
174
|
+
last_event_after: q.last_activity_after,
|
|
175
|
+
last_event_before: q.last_activity_before,
|
|
176
|
+
sort: q.sort,
|
|
177
|
+
order: q.order
|
|
178
|
+
}.compact
|
|
179
|
+
|
|
180
|
+
sandbox_api.list_sandboxes(opts)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
instrument :fetch_sandbox_page, component: 'Nightona.list'
|
|
184
|
+
|
|
185
|
+
# Creates a sandbox with the specified parameters
|
|
186
|
+
#
|
|
187
|
+
# @param params [Nightona::CreateSandboxFromSnapshotParams, Nightona::CreateSandboxFromImageParams] Sandbox creation parameters
|
|
188
|
+
# @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
|
|
189
|
+
# @param on_snapshot_create_logs [Proc]
|
|
190
|
+
# @return [Nightona::Sandbox] The created sandbox
|
|
191
|
+
# @raise [Nightona::Sdk::Error] If auto_stop_interval or auto_archive_interval is negative
|
|
192
|
+
def _create(params, timeout: 60, on_snapshot_create_logs: nil)
|
|
193
|
+
raise Sdk::Error, 'Timeout must be a non-negative number' if timeout.negative?
|
|
194
|
+
|
|
195
|
+
start_time = Time.now
|
|
196
|
+
|
|
197
|
+
raise Sdk::Error, 'auto_stop_interval must be a non-negative integer' if params.auto_stop_interval&.negative?
|
|
198
|
+
|
|
199
|
+
if params.auto_archive_interval&.negative?
|
|
200
|
+
raise Sdk::Error, 'auto_archive_interval must be a non-negative integer'
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
labels = params.labels&.dup || {}
|
|
204
|
+
labels[CODE_TOOLBOX_LANGUAGE_LABEL] = params.language.to_s if params.language
|
|
205
|
+
|
|
206
|
+
create_sandbox = NightonaApiClient::CreateSandbox.new(
|
|
207
|
+
user: params.os_user,
|
|
208
|
+
env: params.env_vars || {},
|
|
209
|
+
labels: labels,
|
|
210
|
+
public: params.public,
|
|
211
|
+
target: config.target,
|
|
212
|
+
auto_stop_interval: params.auto_stop_interval,
|
|
213
|
+
auto_archive_interval: params.auto_archive_interval,
|
|
214
|
+
auto_delete_interval: params.auto_delete_interval,
|
|
215
|
+
volumes: params.volumes,
|
|
216
|
+
network_block_all: params.network_block_all,
|
|
217
|
+
network_allow_list: params.network_allow_list,
|
|
218
|
+
domain_allow_list: params.domain_allow_list,
|
|
219
|
+
linked_sandbox: params.linked_sandbox
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
create_sandbox.snapshot = params.snapshot if params.respond_to?(:snapshot)
|
|
223
|
+
|
|
224
|
+
if params.respond_to?(:image) && params.image.is_a?(String)
|
|
225
|
+
create_sandbox.build_info = NightonaApiClient::CreateBuildInfo.new(
|
|
226
|
+
dockerfile_content: Image.base(params.image).dockerfile
|
|
227
|
+
)
|
|
228
|
+
elsif params.respond_to?(:image) && params.image.is_a?(Image)
|
|
229
|
+
create_sandbox.build_info = NightonaApiClient::CreateBuildInfo.new(
|
|
230
|
+
context_hashes: SnapshotService.process_image_context(object_storage_api, params.image),
|
|
231
|
+
dockerfile_content: params.image.dockerfile
|
|
232
|
+
)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
if params.respond_to?(:resources)
|
|
236
|
+
create_sandbox.cpu = params.resources&.cpu
|
|
237
|
+
create_sandbox.memory = params.resources&.memory
|
|
238
|
+
create_sandbox.disk = params.resources&.disk
|
|
239
|
+
create_sandbox.gpu = params.resources&.gpu
|
|
240
|
+
if params.resources&.gpu_type
|
|
241
|
+
create_sandbox.gpu_type =
|
|
242
|
+
params.resources.gpu_type.is_a?(Array) ? params.resources.gpu_type : [params.resources.gpu_type]
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
response = sandbox_api.create_sandbox(create_sandbox)
|
|
247
|
+
|
|
248
|
+
if response.state == NightonaApiClient::SandboxState::PENDING_BUILD && on_snapshot_create_logs
|
|
249
|
+
# Wait for state to change from PENDING_BUILD before fetching logs
|
|
250
|
+
while response.state == NightonaApiClient::SandboxState::PENDING_BUILD
|
|
251
|
+
sleep(1)
|
|
252
|
+
response = sandbox_api.get_sandbox(response.id)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Get build logs URL from API
|
|
256
|
+
build_logs_response = sandbox_api.get_build_logs_url(response.id)
|
|
257
|
+
uri = URI.parse("#{build_logs_response.url}?follow=true")
|
|
258
|
+
|
|
259
|
+
headers = {}
|
|
260
|
+
sandbox_api.api_client.update_params_for_auth!(headers, nil, ['bearer'])
|
|
261
|
+
Util.stream_async(uri:, headers:, on_chunk: on_snapshot_create_logs)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
sandbox = to_sandbox(sandbox_dto: response)
|
|
265
|
+
|
|
266
|
+
if sandbox.state != NightonaApiClient::SandboxState::STARTED
|
|
267
|
+
sandbox.wait_for_sandbox_start([0.001, timeout - (Time.now - start_time)].max)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
sandbox
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# @return [void]
|
|
274
|
+
# @raise [Nightona::Sdk::Error]
|
|
275
|
+
def ensure_access_token_defined
|
|
276
|
+
return if config.api_key
|
|
277
|
+
|
|
278
|
+
unless config.jwt_token
|
|
279
|
+
raise Sdk::Error,
|
|
280
|
+
'Authentication credentials not found. Set NIGHTONA_API_KEY, or both NIGHTONA_JWT_TOKEN and ' \
|
|
281
|
+
'NIGHTONA_ORGANIZATION_ID. These can also be provided via Nightona::Config.'
|
|
282
|
+
end
|
|
283
|
+
return if config.organization_id
|
|
284
|
+
|
|
285
|
+
raise Sdk::Error,
|
|
286
|
+
'NIGHTONA_ORGANIZATION_ID is required when authenticating with NIGHTONA_JWT_TOKEN. ' \
|
|
287
|
+
'It can also be provided via Nightona::Config.'
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# @return [NightonaApiClient::ApiClient]
|
|
291
|
+
def build_api_client
|
|
292
|
+
NightonaApiClient::ApiClient.new(api_client_config).tap do |client|
|
|
293
|
+
client.default_headers[HEADER_SOURCE] = SOURCE_RUBY
|
|
294
|
+
client.default_headers[HEADER_SDK_VERSION] = Sdk::VERSION
|
|
295
|
+
client.default_headers[HEADER_ORGANIZATION_ID] = config.organization_id if config.jwt_token
|
|
296
|
+
client.user_agent = "sdk-ruby/#{Sdk::VERSION}"
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# @return [NightonaApiClient::Configuration]
|
|
301
|
+
def api_client_config
|
|
302
|
+
NightonaApiClient::Configuration.new.configure do |api_config|
|
|
303
|
+
uri = URI(config.api_url)
|
|
304
|
+
api_config.scheme = uri.scheme
|
|
305
|
+
api_config.host = uri.authority # Includes hostname:port
|
|
306
|
+
api_config.base_path = uri.path
|
|
307
|
+
|
|
308
|
+
api_config.access_token_getter = proc { config.api_key || config.jwt_token }
|
|
309
|
+
api_config
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# @param sandbox_dto [NightonaApiClient::Sandbox, NightonaApiClient::SandboxListItem]
|
|
314
|
+
# @return [Nightona::Sandbox]
|
|
315
|
+
def to_sandbox(sandbox_dto:)
|
|
316
|
+
Sandbox.new(
|
|
317
|
+
sandbox_dto:,
|
|
318
|
+
config:,
|
|
319
|
+
sandbox_api:,
|
|
320
|
+
otel_state: @otel_state
|
|
321
|
+
)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
SOURCE_RUBY = 'sdk-ruby'
|
|
325
|
+
private_constant :SOURCE_RUBY
|
|
326
|
+
|
|
327
|
+
HEADER_SOURCE = 'X-Nightona-Source'
|
|
328
|
+
private_constant :HEADER_SOURCE
|
|
329
|
+
|
|
330
|
+
HEADER_SDK_VERSION = 'X-Nightona-SDK-Version'
|
|
331
|
+
private_constant :HEADER_SDK_VERSION
|
|
332
|
+
|
|
333
|
+
HEADER_ORGANIZATION_ID = 'X-Nightona-Organization-ID'
|
|
334
|
+
private_constant :HEADER_ORGANIZATION_ID
|
|
335
|
+
end
|
|
336
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Copyright Daytona Platforms Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
# frozen_string_literal: true
|
|
5
|
+
|
|
6
|
+
require 'digest'
|
|
7
|
+
require 'fileutils'
|
|
8
|
+
require 'pathname'
|
|
9
|
+
require 'tempfile'
|
|
10
|
+
require 'zlib'
|
|
11
|
+
require 'aws-sdk-s3'
|
|
12
|
+
|
|
13
|
+
module Nightona
|
|
14
|
+
class ObjectStorage
|
|
15
|
+
# @return [String] The name of the S3 bucket used for object storage
|
|
16
|
+
attr_reader :bucket_name
|
|
17
|
+
|
|
18
|
+
# @return [Aws::S3::Client] The S3 client
|
|
19
|
+
attr_reader :s3_client
|
|
20
|
+
|
|
21
|
+
# Initialize ObjectStorage with S3-compatible credentials
|
|
22
|
+
#
|
|
23
|
+
# @param endpoint_url [String] The endpoint URL for the object storage service
|
|
24
|
+
# @param aws_access_key_id [String] The access key ID for the object storage service
|
|
25
|
+
# @param aws_secret_access_key [String] The secret access key for the object storage service
|
|
26
|
+
# @param aws_session_token [String] The session token for the object storage service
|
|
27
|
+
# @param bucket_name [String] The name of the bucket to use (defaults to "nightona-volume-builds")
|
|
28
|
+
# @param region [String] AWS region (defaults to us-east-1)
|
|
29
|
+
def initialize(endpoint_url:, aws_access_key_id:, aws_secret_access_key:, aws_session_token:, # rubocop:disable Metrics/ParameterLists
|
|
30
|
+
bucket_name: DEFAULT_BUCKET_NAME, region: DEFAULT_REGION)
|
|
31
|
+
@bucket_name = bucket_name
|
|
32
|
+
@s3_client = Aws::S3::Client.new(
|
|
33
|
+
region:,
|
|
34
|
+
endpoint: endpoint_url,
|
|
35
|
+
access_key_id: aws_access_key_id,
|
|
36
|
+
secret_access_key: aws_secret_access_key,
|
|
37
|
+
session_token: aws_session_token
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Uploads a file to the object storage service
|
|
42
|
+
#
|
|
43
|
+
# @param path [String] The path to the file to upload
|
|
44
|
+
# @param organization_id [String] The organization ID to use
|
|
45
|
+
# @param archive_base_path [String, nil] The base path to use for the archive
|
|
46
|
+
# @return [String] The hash of the uploaded file
|
|
47
|
+
# @raise [Errno::ENOENT] If the path does not exist
|
|
48
|
+
def upload(path, organization_id, archive_base_path = nil)
|
|
49
|
+
raise Errno::ENOENT, "Path does not exist: #{path}" unless File.exist?(path)
|
|
50
|
+
|
|
51
|
+
path_hash = compute_hash_for_path_md5(path, archive_base_path)
|
|
52
|
+
s3_key = "#{organization_id}/#{path_hash}/context.tar"
|
|
53
|
+
|
|
54
|
+
return path_hash if file_exists_in_s3(s3_key)
|
|
55
|
+
|
|
56
|
+
upload_as_tar(s3_key, path, archive_base_path)
|
|
57
|
+
|
|
58
|
+
path_hash
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Compute the base path for an archive. Returns normalized path without the root
|
|
62
|
+
# (drive letter or leading slash).
|
|
63
|
+
#
|
|
64
|
+
# @param path_str [String] The path to compute the base path for
|
|
65
|
+
# @return [String] The base path for the given path
|
|
66
|
+
def self.compute_archive_base_path(path_str)
|
|
67
|
+
normalized_path = File.basename(path_str)
|
|
68
|
+
|
|
69
|
+
# Remove drive letter for Windows paths (e.g., C:)
|
|
70
|
+
path_without_drive = normalized_path.gsub(/^[A-Za-z]:/, '')
|
|
71
|
+
|
|
72
|
+
# Remove leading separators (both / and \)
|
|
73
|
+
path_without_drive.gsub(%r{^[/\\]+}, '')
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# Computes the MD5 hash for a given path
|
|
79
|
+
#
|
|
80
|
+
# @param path_str [String] The path to compute the hash for
|
|
81
|
+
# @param archive_base_path [String, nil] The base path to use for the archive
|
|
82
|
+
# @return [String] The MD5 hash for the given path
|
|
83
|
+
def compute_hash_for_path_md5(path_str, archive_base_path = nil) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
84
|
+
md5_hasher = Digest::MD5.new
|
|
85
|
+
abs_path_str = File.expand_path(path_str)
|
|
86
|
+
|
|
87
|
+
archive_base_path = self.class.compute_archive_base_path(path_str) if archive_base_path.nil?
|
|
88
|
+
md5_hasher.update(archive_base_path)
|
|
89
|
+
|
|
90
|
+
if File.file?(abs_path_str)
|
|
91
|
+
File.open(abs_path_str, 'rb') do |f|
|
|
92
|
+
while (chunk = f.read(8192))
|
|
93
|
+
md5_hasher.update(chunk)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
else
|
|
97
|
+
Dir.glob(File.join(abs_path_str, '**', '*')).each do |file_path|
|
|
98
|
+
next unless File.file?(file_path)
|
|
99
|
+
|
|
100
|
+
rel_path = Pathname.new(file_path).relative_path_from(Pathname.new(abs_path_str)).to_s
|
|
101
|
+
|
|
102
|
+
md5_hasher.update(rel_path)
|
|
103
|
+
|
|
104
|
+
File.open(file_path, 'rb') do |f|
|
|
105
|
+
while (chunk = f.read(8192))
|
|
106
|
+
md5_hasher.update(chunk)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Handle empty directories
|
|
112
|
+
Dir
|
|
113
|
+
.glob(File.join(abs_path_str, '**', '*'))
|
|
114
|
+
.select { |path| File.directory?(path) && Dir.empty?(path) }
|
|
115
|
+
.each do |empty_dir|
|
|
116
|
+
rel_dir = Pathname.new(empty_dir).relative_path_from(Pathname.new(abs_path_str)).to_s
|
|
117
|
+
md5_hasher.update(rel_dir)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
md5_hasher.hexdigest
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Checks whether a specific object exists at the given path
|
|
125
|
+
#
|
|
126
|
+
# @param file_path [String] Full object path, e.g. "org/abcd123/context.tar"
|
|
127
|
+
# @return [Boolean] True if the object exists, False otherwise
|
|
128
|
+
def file_exists_in_s3(file_path)
|
|
129
|
+
s3_client.head_object(bucket: bucket_name, key: file_path)
|
|
130
|
+
true
|
|
131
|
+
rescue Aws::S3::Errors::NotFound
|
|
132
|
+
false
|
|
133
|
+
rescue StandardError => e
|
|
134
|
+
Sdk.logger.debug("Error checking file existence: #{e.message}")
|
|
135
|
+
false
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Uploads a file to the object storage service as a tar
|
|
139
|
+
#
|
|
140
|
+
# @param s3_key [String] The key to upload the file to
|
|
141
|
+
# @param source_path [String] The path to the file to upload
|
|
142
|
+
# @param archive_base_path [String, nil] The base path to use for the archive
|
|
143
|
+
def upload_as_tar(s3_key, source_path, archive_base_path = nil) # rubocop:disable Metrics/MethodLength
|
|
144
|
+
source_path = File.expand_path(source_path)
|
|
145
|
+
|
|
146
|
+
self.class.compute_archive_base_path(source_path) if archive_base_path.nil?
|
|
147
|
+
|
|
148
|
+
temp_file = Tempfile.new(['context', '.tar'])
|
|
149
|
+
|
|
150
|
+
begin
|
|
151
|
+
system('tar', '-cf', temp_file.path, '-C', File.dirname(source_path), File.basename(source_path))
|
|
152
|
+
|
|
153
|
+
File.open(temp_file.path, 'rb') do |file|
|
|
154
|
+
s3_client.put_object(
|
|
155
|
+
bucket: bucket_name,
|
|
156
|
+
key: s3_key,
|
|
157
|
+
body: file
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
ensure
|
|
161
|
+
temp_file.close
|
|
162
|
+
temp_file.unlink
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
DEFAULT_BUCKET_NAME = 'nightona-volume-builds'
|
|
167
|
+
private_constant :DEFAULT_BUCKET_NAME
|
|
168
|
+
|
|
169
|
+
DEFAULT_REGION = 'us-east-1'
|
|
170
|
+
private_constant :DEFAULT_REGION
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# Copyright Daytona Platforms Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
# frozen_string_literal: true
|
|
5
|
+
|
|
6
|
+
module Nightona
|
|
7
|
+
# Holds OTel provider state for the SDK.
|
|
8
|
+
class OtelState
|
|
9
|
+
attr_reader :tracer_provider
|
|
10
|
+
attr_reader :meter_provider
|
|
11
|
+
attr_reader :tracer
|
|
12
|
+
attr_reader :meter
|
|
13
|
+
|
|
14
|
+
def initialize(tracer_provider:, meter_provider:, tracer:, meter:)
|
|
15
|
+
@tracer_provider = tracer_provider
|
|
16
|
+
@meter_provider = meter_provider
|
|
17
|
+
@tracer = tracer
|
|
18
|
+
@meter = meter
|
|
19
|
+
@histograms = {}
|
|
20
|
+
@histograms_mutex = Mutex.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Returns a cached histogram for the given metric name.
|
|
24
|
+
def histogram(name)
|
|
25
|
+
@histograms_mutex.synchronize do
|
|
26
|
+
@histograms[name] ||= meter.create_histogram(name, unit: 'ms')
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def shutdown
|
|
31
|
+
tracer_provider.shutdown
|
|
32
|
+
meter_provider.shutdown
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Initializes OTel providers, sets globals, installs Typhoeus propagation.
|
|
37
|
+
# OTel gems are required lazily so they are never loaded when disabled.
|
|
38
|
+
#
|
|
39
|
+
# @param sdk_version [String]
|
|
40
|
+
# @return [OtelState]
|
|
41
|
+
def self.init_otel(sdk_version) # rubocop:disable Metrics/MethodLength
|
|
42
|
+
require 'opentelemetry-sdk'
|
|
43
|
+
require 'opentelemetry-metrics-sdk'
|
|
44
|
+
require 'opentelemetry-exporter-otlp'
|
|
45
|
+
require 'opentelemetry-exporter-otlp-metrics'
|
|
46
|
+
|
|
47
|
+
resource = OpenTelemetry::SDK::Resources::Resource.create(
|
|
48
|
+
'service.name' => 'nightona-ruby-sdk',
|
|
49
|
+
'service.version' => sdk_version
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
tracer_provider = OpenTelemetry::SDK::Trace::TracerProvider.new(resource:)
|
|
53
|
+
tracer_provider.add_span_processor(
|
|
54
|
+
OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
|
|
55
|
+
OpenTelemetry::Exporter::OTLP::Exporter.new
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
OpenTelemetry.tracer_provider = tracer_provider
|
|
59
|
+
|
|
60
|
+
meter_provider = OpenTelemetry::SDK::Metrics::MeterProvider.new(resource:)
|
|
61
|
+
meter_provider.add_metric_reader(
|
|
62
|
+
OpenTelemetry::SDK::Metrics::Export::PeriodicMetricReader.new(
|
|
63
|
+
exporter: OpenTelemetry::Exporter::OTLP::Metrics::MetricsExporter.new
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
OpenTelemetry.meter_provider = meter_provider
|
|
67
|
+
|
|
68
|
+
tracer = tracer_provider.tracer('nightona-ruby-sdk', sdk_version)
|
|
69
|
+
meter = meter_provider.meter('nightona-ruby-sdk')
|
|
70
|
+
|
|
71
|
+
# Install Typhoeus trace-context propagation
|
|
72
|
+
install_typhoeus_propagation
|
|
73
|
+
|
|
74
|
+
OtelState.new(tracer_provider:, meter_provider:, tracer:, meter:)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Flushes and shuts down OTel providers.
|
|
78
|
+
def self.shutdown_otel(state)
|
|
79
|
+
state&.shutdown
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Wraps a block with OTel span creation and duration histogram recording.
|
|
83
|
+
# When otel_state is nil (OTel disabled), calls the block directly.
|
|
84
|
+
#
|
|
85
|
+
# @param otel_state [OtelState, nil]
|
|
86
|
+
# @param component [String]
|
|
87
|
+
# @param method_name [String]
|
|
88
|
+
# @return [Object] The block's return value
|
|
89
|
+
def self.with_instrumentation(otel_state, component, method_name, &block) # rubocop:disable Metrics/MethodLength
|
|
90
|
+
return block.call unless otel_state
|
|
91
|
+
|
|
92
|
+
span_name = "#{component}.#{method_name}"
|
|
93
|
+
metric_name = "#{to_snake_case(span_name)}_duration"
|
|
94
|
+
status = 'success'
|
|
95
|
+
|
|
96
|
+
otel_state.tracer.in_span(
|
|
97
|
+
span_name,
|
|
98
|
+
attributes: { 'component' => component, 'method' => method_name }
|
|
99
|
+
) do |_span|
|
|
100
|
+
start_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
101
|
+
begin
|
|
102
|
+
block.call
|
|
103
|
+
rescue StandardError => e
|
|
104
|
+
status = 'error'
|
|
105
|
+
raise e
|
|
106
|
+
ensure
|
|
107
|
+
duration_ms = (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start_time) * 1000.0
|
|
108
|
+
otel_state.histogram(metric_name).record(
|
|
109
|
+
duration_ms,
|
|
110
|
+
attributes: { 'component' => component, 'method' => method_name, 'status' => status }
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Converts "ClassName.method_name" to "class_name_method_name".
|
|
117
|
+
def self.to_snake_case(str)
|
|
118
|
+
result = +''
|
|
119
|
+
str.each_char.with_index do |char, i|
|
|
120
|
+
if char == '.'
|
|
121
|
+
result << '_'
|
|
122
|
+
elsif char =~ /[A-Z]/ && i > 0 && str[i - 1] != '.'
|
|
123
|
+
result << '_' << char.downcase
|
|
124
|
+
else
|
|
125
|
+
result << char.downcase
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
result
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Installs Typhoeus.before callback for W3C trace-context propagation.
|
|
132
|
+
def self.install_typhoeus_propagation
|
|
133
|
+
return unless defined?(Typhoeus)
|
|
134
|
+
|
|
135
|
+
Typhoeus.before do |request|
|
|
136
|
+
headers = request.options[:headers] ||= {}
|
|
137
|
+
OpenTelemetry.propagation.inject(headers)
|
|
138
|
+
true
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Mixin that provides the `instrument` class macro for wrapping methods
|
|
143
|
+
# with OTel spans and metrics.
|
|
144
|
+
module Instrumentation
|
|
145
|
+
def self.included(base)
|
|
146
|
+
base.extend(ClassMethods)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
module ClassMethods
|
|
150
|
+
# Instruments the listed methods with OTel tracing/metrics.
|
|
151
|
+
# Must be called after all target methods are defined.
|
|
152
|
+
#
|
|
153
|
+
# @param method_names [Array<Symbol>] methods to instrument
|
|
154
|
+
# @param component [String] component name for span/metric attributes
|
|
155
|
+
def instrument(*method_names, component:) # rubocop:disable Metrics/MethodLength
|
|
156
|
+
method_names.each do |method_name|
|
|
157
|
+
original = instance_method(method_name)
|
|
158
|
+
|
|
159
|
+
# Detect original visibility
|
|
160
|
+
visibility = if private_method_defined?(method_name, false)
|
|
161
|
+
:private
|
|
162
|
+
elsif protected_method_defined?(method_name, false)
|
|
163
|
+
:protected
|
|
164
|
+
else
|
|
165
|
+
:public
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
define_method(method_name) do |*args, **kwargs, &blk|
|
|
169
|
+
::Nightona.with_instrumentation(otel_state, component, method_name.to_s) do
|
|
170
|
+
original.bind_call(self, *args, **kwargs, &blk)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Restore visibility
|
|
175
|
+
case visibility
|
|
176
|
+
when :private then private method_name
|
|
177
|
+
when :protected then protected method_name
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|