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.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +22 -0
  4. data/.ruby-version +1 -0
  5. data/CODE_OF_CONDUCT.md +132 -0
  6. data/LICENSE +190 -0
  7. data/README.md +184 -0
  8. data/Rakefile +12 -0
  9. data/lib/nightona/code_interpreter.rb +359 -0
  10. data/lib/nightona/common/charts.rb +124 -0
  11. data/lib/nightona/common/code_interpreter.rb +56 -0
  12. data/lib/nightona/common/code_language.rb +14 -0
  13. data/lib/nightona/common/file_system.rb +26 -0
  14. data/lib/nightona/common/git.rb +19 -0
  15. data/lib/nightona/common/image.rb +500 -0
  16. data/lib/nightona/common/nightona.rb +230 -0
  17. data/lib/nightona/common/process.rb +149 -0
  18. data/lib/nightona/common/pty.rb +309 -0
  19. data/lib/nightona/common/resources.rb +39 -0
  20. data/lib/nightona/common/response.rb +83 -0
  21. data/lib/nightona/common/snapshot.rb +124 -0
  22. data/lib/nightona/computer_use.rb +919 -0
  23. data/lib/nightona/config.rb +116 -0
  24. data/lib/nightona/file_system.rb +451 -0
  25. data/lib/nightona/file_transfer.rb +383 -0
  26. data/lib/nightona/git.rb +334 -0
  27. data/lib/nightona/lsp_server.rb +139 -0
  28. data/lib/nightona/nightona.rb +336 -0
  29. data/lib/nightona/object_storage.rb +172 -0
  30. data/lib/nightona/otel.rb +183 -0
  31. data/lib/nightona/process.rb +550 -0
  32. data/lib/nightona/sandbox.rb +751 -0
  33. data/lib/nightona/sdk/version.rb +10 -0
  34. data/lib/nightona/sdk.rb +56 -0
  35. data/lib/nightona/snapshot_service.rb +238 -0
  36. data/lib/nightona/util.rb +80 -0
  37. data/lib/nightona/volume.rb +46 -0
  38. data/lib/nightona/volume_service.rb +61 -0
  39. data/lib/nightona.rb +10 -0
  40. data/project.json +100 -0
  41. data/scripts/generate-docs.rb +402 -0
  42. data/sig/nightona/sdk.rbs +6 -0
  43. metadata +242 -0
@@ -0,0 +1,751 @@
1
+ # Copyright Daytona Platforms Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ # frozen_string_literal: true
5
+
6
+ require 'timeout'
7
+
8
+ module Nightona
9
+ class Sandbox # rubocop:disable Metrics/ClassLength
10
+ include Instrumentation
11
+
12
+ DEFAULT_TIMEOUT = 60
13
+
14
+ # @return [String] The ID of the sandbox
15
+ attr_reader :id
16
+
17
+ # @return [String] The organization ID of the sandbox
18
+ attr_reader :organization_id
19
+
20
+ # @return [String] The snapshot used for the sandbox
21
+ attr_reader :snapshot
22
+
23
+ # @return [String] The user associated with the project
24
+ attr_reader :user
25
+
26
+ # @return [Hash<String, String>, nil] Environment variables for the sandbox.
27
+ # Not returned by list results; call #refresh on each item to populate.
28
+ attr_reader :env
29
+
30
+ # @return [Hash<String, String>] Labels for the sandbox
31
+ attr_reader :labels
32
+
33
+ # @return [Boolean] Whether the sandbox http preview is public
34
+ attr_reader :public
35
+
36
+ # @return [Boolean, nil] Whether to block all network access for the sandbox.
37
+ # Not returned by list results; call #refresh on each item to populate.
38
+ attr_reader :network_block_all
39
+
40
+ # @return [String, nil] Comma-separated list of allowed CIDR network addresses for the sandbox.
41
+ # Not returned by list results; call #refresh on each item to populate.
42
+ attr_reader :network_allow_list
43
+
44
+ # @return [String, nil] Comma-separated list of allowed domains for the sandbox.
45
+ # Not returned by list results; call #refresh on each item to populate.
46
+ attr_reader :domain_allow_list
47
+
48
+ # @return [String] The target environment for the sandbox
49
+ attr_reader :target
50
+
51
+ # @return [Float] The CPU quota for the sandbox
52
+ attr_reader :cpu
53
+
54
+ # @return [Float] The GPU quota for the sandbox
55
+ attr_reader :gpu
56
+
57
+ # @return [Float] The memory quota for the sandbox
58
+ attr_reader :memory
59
+
60
+ # @return [Float] The disk quota for the sandbox
61
+ attr_reader :disk
62
+
63
+ # @return [NightonaApiClient::SandboxState] The state of the sandbox
64
+ attr_reader :state
65
+
66
+ # @return [NightonaApiClient::SandboxDesiredState] The desired state of the sandbox
67
+ attr_reader :desired_state
68
+
69
+ # @return [String] The error reason of the sandbox
70
+ attr_reader :error_reason
71
+
72
+ # @return [String] The state of the backup
73
+ attr_reader :backup_state
74
+
75
+ # @return [String, nil] The creation timestamp of the last backup.
76
+ # Not returned by list results; call #refresh on each item to populate.
77
+ attr_reader :backup_created_at
78
+
79
+ # @return [Float] Auto-stop interval in minutes (0 means disabled)
80
+ attr_reader :auto_stop_interval
81
+
82
+ # @return [Float] Auto-archive interval in minutes
83
+ attr_reader :auto_archive_interval
84
+
85
+ # @return [Float] Auto-delete interval in minutes
86
+ # (negative value means disabled, 0 means delete immediately upon stopping)
87
+ attr_reader :auto_delete_interval
88
+
89
+ # @return [Array<NightonaApiClient::SandboxVolume>, nil] Volumes attached to the sandbox.
90
+ # Not returned by list results; call #refresh on each item to populate.
91
+ attr_reader :volumes
92
+
93
+ # @return [NightonaApiClient::BuildInfo, nil] Build information for the sandbox if it was
94
+ # created from a dynamic build.
95
+ # Not returned by list results; call #refresh on each item to populate.
96
+ attr_reader :build_info
97
+
98
+ # @return [String] The creation timestamp of the sandbox
99
+ attr_reader :created_at
100
+
101
+ # @return [String] The last update timestamp of the sandbox
102
+ attr_reader :updated_at
103
+
104
+ # @return [String] The last activity timestamp of the sandbox
105
+ attr_reader :last_activity_at
106
+
107
+ # @return [String] The version of the daemon running in the sandbox
108
+ attr_reader :daemon_version
109
+
110
+ # @return [Nightona::Config]
111
+ attr_reader :config
112
+
113
+ # @return [NightonaApiClient::SandboxApi]
114
+ attr_reader :sandbox_api
115
+
116
+ # @return [Nightona::Process]
117
+ attr_reader :process
118
+
119
+ # @return [Nightona::FileSystem]
120
+ attr_reader :fs
121
+
122
+ # @return [Nightona::Git]
123
+ attr_reader :git
124
+
125
+ # @return [Nightona::ComputerUse]
126
+ attr_reader :computer_use
127
+
128
+ # @return [Nightona::CodeInterpreter]
129
+ attr_reader :code_interpreter
130
+
131
+ # @params config [Nightona::Config]
132
+ # @params sandbox_api [NightonaApiClient::SandboxApi]
133
+ # @params sandbox_dto [NightonaApiClient::Sandbox, NightonaApiClient::SandboxListItem]
134
+ # @params otel_state [Nightona::OtelState, nil]
135
+ def initialize(sandbox_dto:, config:, sandbox_api:, otel_state: nil) # rubocop:disable Metrics/MethodLength
136
+ process_response(sandbox_dto)
137
+ @config = config
138
+ @sandbox_api = sandbox_api
139
+ @otel_state = otel_state
140
+
141
+ # Create toolbox API clients with dynamic configuration
142
+ toolbox_api_config = build_toolbox_api_config
143
+
144
+ # Helper to create API client with authentication header
145
+ create_authenticated_client = lambda do
146
+ client = NightonaToolboxApiClient::ApiClient.new(toolbox_api_config)
147
+ client.default_headers['Authorization'] = "Bearer #{config.api_key || config.jwt_token}"
148
+ client.default_headers['X-Nightona-Source'] = 'sdk-ruby'
149
+ client.default_headers['X-Nightona-SDK-Version'] = Sdk::VERSION
150
+ client.default_headers['X-Nightona-Organization-ID'] = config.organization_id if config.jwt_token
151
+ client.user_agent = "sdk-ruby/#{Sdk::VERSION}"
152
+ client
153
+ end
154
+
155
+ process_api = NightonaToolboxApiClient::ProcessApi.new(create_authenticated_client.call)
156
+ fs_api = NightonaToolboxApiClient::FileSystemApi.new(create_authenticated_client.call)
157
+ git_api = NightonaToolboxApiClient::GitApi.new(create_authenticated_client.call)
158
+ lsp_api = NightonaToolboxApiClient::LspApi.new(create_authenticated_client.call)
159
+ computer_use_api = NightonaToolboxApiClient::ComputerUseApi.new(create_authenticated_client.call)
160
+ interpreter_api = NightonaToolboxApiClient::InterpreterApi.new(create_authenticated_client.call)
161
+ info_api = NightonaToolboxApiClient::InfoApi.new(create_authenticated_client.call)
162
+
163
+ @process = Process.new(
164
+ sandbox_id: id,
165
+ toolbox_api: process_api,
166
+ get_preview_link: proc { |port| preview_url(port) },
167
+ language: (labels || {}).fetch(CODE_TOOLBOX_LANGUAGE_LABEL, 'python'),
168
+ otel_state:
169
+ )
170
+ @fs = FileSystem.new(sandbox_id: id, toolbox_api: fs_api, otel_state:)
171
+ @git = Git.new(sandbox_id: id, toolbox_api: git_api, otel_state:)
172
+ @computer_use = ComputerUse.new(sandbox_id: id, toolbox_api: computer_use_api, otel_state:)
173
+ @code_interpreter = CodeInterpreter.new(
174
+ sandbox_id: id,
175
+ toolbox_api: interpreter_api,
176
+ get_preview_link: proc { |port| preview_url(port) },
177
+ otel_state:
178
+ )
179
+ @lsp_api = lsp_api
180
+ @info_api = info_api
181
+ end
182
+
183
+ # Archives the sandbox, making it inactive and preserving its state. When sandboxes are
184
+ # archived, the entire filesystem state is moved to cost-effective object storage, making it
185
+ # possible to keep sandboxes available for an extended period. The tradeoff between archived
186
+ # and stopped states is that starting an archived sandbox takes more time, depending on its size.
187
+ # Sandbox must be stopped before archiving.
188
+ #
189
+ # @return [void]
190
+ def archive
191
+ sandbox_api.archive_sandbox(id)
192
+ refresh
193
+ end
194
+
195
+ # Sets the auto-archive interval for the Sandbox.
196
+ # The Sandbox will automatically archive after being continuously stopped for the specified interval.
197
+ #
198
+ # @param interval [Integer]
199
+ # @return [Integer]
200
+ # @raise [Nightona:Sdk::Error]
201
+ def auto_archive_interval=(interval)
202
+ raise Sdk::Error, 'Auto-archive interval must be a non-negative integer' if interval.negative?
203
+
204
+ sandbox_api.set_auto_archive_interval(id, interval)
205
+ @auto_archive_interval = interval
206
+ end
207
+
208
+ # Sets the auto-delete interval for the Sandbox.
209
+ # The Sandbox will automatically delete after being continuously stopped for the specified interval.
210
+ #
211
+ # @param interval [Integer]
212
+ # @return [Integer]
213
+ # @raise [Nightona:Sdk::Error]
214
+ def auto_delete_interval=(interval)
215
+ sandbox_api.set_auto_delete_interval(id, interval)
216
+ @auto_delete_interval = interval
217
+ end
218
+
219
+ # Updates outbound network policy on the runner (block all, restore access, or CIDR allow list).
220
+ #
221
+ # @param network_block_all [Boolean, nil]
222
+ # @param network_allow_list [String, nil]
223
+ # @param domain_allow_list [String, nil]
224
+ # @return [void]
225
+ # @raise [Nightona::Sdk::Error]
226
+ def update_network_settings(network_block_all: nil, network_allow_list: nil, domain_allow_list: nil)
227
+ if network_block_all.nil? && network_allow_list.nil? && domain_allow_list.nil?
228
+ raise Sdk::Error,
229
+ 'At least one of network_block_all, network_allow_list or domain_allow_list must be provided'
230
+ end
231
+
232
+ body = NightonaApiClient::UpdateSandboxNetworkSettings.new(
233
+ network_block_all:,
234
+ network_allow_list:,
235
+ domain_allow_list:
236
+ )
237
+ data = sandbox_api.update_network_settings(id, body)
238
+ @network_block_all = data.network_block_all
239
+ @network_allow_list = data.network_allow_list
240
+ @domain_allow_list = data.domain_allow_list
241
+ end
242
+
243
+ # Sets the auto-stop interval for the Sandbox.
244
+ # The Sandbox will automatically stop after being idle (no new events) for the specified interval.
245
+ # Events include any state changes or interactions with the Sandbox through the SDK.
246
+ # Interactions using Sandbox Previews are not included.
247
+ #
248
+ # @param interval [Integer]
249
+ # @return [Integer]
250
+ # @raise [Nightona:Sdk::Error]
251
+ def auto_stop_interval=(interval)
252
+ raise Sdk::Error, 'Auto-stop interval must be a non-negative integer' if interval.negative?
253
+
254
+ sandbox_api.set_autostop_interval(id, interval)
255
+ @auto_stop_interval = interval
256
+ end
257
+
258
+ # Creates an SSH access token for the sandbox.
259
+ #
260
+ # @param expires_in_minutes [Integer] TThe number of minutes the SSH access token will be valid for
261
+ # @return [NightonaApiClient::SshAccessDto]
262
+ def create_ssh_access(expires_in_minutes) = sandbox_api.create_ssh_access(id, { expires_in_minutes: })
263
+
264
+ # @return [void]
265
+ def delete
266
+ sandbox_api.delete_sandbox(id)
267
+ refresh
268
+ rescue NightonaApiClient::ApiError => e
269
+ raise unless e.code == 404
270
+
271
+ @state = 'destroyed'
272
+ end
273
+
274
+ # Gets the user's home directory path for the logged in user inside the Sandbox.
275
+ #
276
+ # @return [String] The absolute path to the Sandbox user's home directory for the logged in user
277
+ #
278
+ # @example
279
+ # user_home_dir = sandbox.get_user_home_dir
280
+ # puts "Sandbox user home: #{user_home_dir}"
281
+ def get_user_home_dir
282
+ @info_api.get_user_home_dir.dir
283
+ rescue StandardError => e
284
+ raise Sdk::Error, "Failed to get user home directory: #{e.message}"
285
+ end
286
+
287
+ # Gets the working directory path inside the Sandbox.
288
+ #
289
+ # @return [String] The absolute path to the Sandbox working directory. Uses the WORKDIR specified
290
+ # in the Dockerfile if present, or falling back to the user's home directory if not.
291
+ #
292
+ # @example
293
+ # work_dir = sandbox.get_work_dir
294
+ # puts "Sandbox working directory: #{work_dir}"
295
+ def get_work_dir
296
+ @info_api.get_work_dir.dir
297
+ rescue StandardError => e
298
+ raise Sdk::Error, "Failed to get working directory path: #{e.message}"
299
+ end
300
+
301
+ # Sets labels for the Sandbox.
302
+ #
303
+ # @param labels [Hash<String, String>]
304
+ # @return [Hash<String, String>]
305
+ def labels=(labels)
306
+ @labels = sandbox_api.replace_labels(id, NightonaApiClient::SandboxLabels.build_from_hash(labels:)).labels
307
+ end
308
+
309
+ # Retrieves the preview link for the sandbox at the specified port. If the port is closed,
310
+ # it will be opened automatically. For private sandboxes, a token is included to grant access
311
+ # to the URL.
312
+ #
313
+ # @param port [Integer]
314
+ # @return [NightonaApiClient::PortPreviewUrl]
315
+ def preview_url(port) = sandbox_api.get_port_preview_url(id, port)
316
+
317
+ # Creates a signed preview URL for the sandbox at the specified port.
318
+ #
319
+ # @param port [Integer] The port to open the preview link on
320
+ # @param expires_in_seconds [Integer, nil] The number of seconds the signed preview URL
321
+ # will be valid for. Defaults to 60 seconds.
322
+ # @return [NightonaApiClient::SignedPortPreviewUrl] The signed preview URL response object
323
+ #
324
+ # @example
325
+ # signed_url = sandbox.create_signed_preview_url(3000, 120)
326
+ # puts "Signed URL: #{signed_url.url}"
327
+ # puts "Token: #{signed_url.token}"
328
+ def create_signed_preview_url(port, expires_in_seconds = nil)
329
+ sandbox_api.get_signed_port_preview_url(id, port, { expires_in_seconds: })
330
+ end
331
+
332
+ # Expires a signed preview URL for the sandbox at the specified port.
333
+ #
334
+ # @param port [Integer] The port to expire the signed preview URL on
335
+ # @param token [String] The token to expire
336
+ # @return [void]
337
+ #
338
+ # @example
339
+ # sandbox.expire_signed_preview_url(3000, "token-value")
340
+ def expire_signed_preview_url(port, token)
341
+ sandbox_api.expire_signed_port_preview_url(id, port, token)
342
+ end
343
+
344
+ # Refresh the Sandbox data from the API.
345
+ #
346
+ # @return [void]
347
+ def refresh = process_response(sandbox_api.get_sandbox(id))
348
+
349
+ # Refreshes the sandbox activity to reset the timer for automated lifecycle management actions.
350
+ #
351
+ # This method updates the sandbox's last activity timestamp without changing its state.
352
+ # It is useful for keeping long-running sessions alive while there is still user activity.
353
+ #
354
+ # @return [void]
355
+ #
356
+ # @example
357
+ # sandbox.refresh_activity
358
+ def refresh_activity
359
+ sandbox_api.update_last_activity(id)
360
+ nil
361
+ rescue StandardError => e
362
+ raise Sdk::Error, "Failed to refresh sandbox activity: #{e.message}"
363
+ end
364
+
365
+ # Revokes an SSH access token for the sandbox.
366
+ #
367
+ # @param token [String]
368
+ # @return [void]
369
+ def revoke_ssh_access(token) = sandbox_api.revoke_ssh_access(id, token:)
370
+
371
+ # Starts the Sandbox and waits for it to be ready.
372
+ #
373
+ # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
374
+ # @return [void]
375
+ def start(timeout = DEFAULT_TIMEOUT)
376
+ with_timeout(
377
+ timeout:,
378
+ message: "Sandbox #{id} failed to become ready within the #{timeout} seconds timeout period",
379
+ setup: proc { process_response(sandbox_api.start_sandbox(id)) }
380
+ ) { wait_for_states(operation: OPERATION_START, target_states: [NightonaApiClient::SandboxState::STARTED]) }
381
+ end
382
+
383
+ # Recovers the Sandbox from a recoverable error and waits for it to be ready.
384
+ #
385
+ # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
386
+ # @return [void]
387
+ #
388
+ # @example
389
+ # sandbox = nightona.get('my-sandbox-id')
390
+ # sandbox.recover(timeout: 40) # Wait up to 40 seconds
391
+ # puts 'Sandbox recovered successfully'
392
+ def recover(timeout = DEFAULT_TIMEOUT)
393
+ with_timeout(
394
+ timeout:,
395
+ message: "Sandbox #{id} failed to recover within the #{timeout} seconds timeout period",
396
+ setup: proc { process_response(sandbox_api.recover_sandbox(id)) }
397
+ ) { wait_for_states(operation: OPERATION_START, target_states: [NightonaApiClient::SandboxState::STARTED]) }
398
+ rescue StandardError => e
399
+ raise Sdk::Error, "Failed to recover sandbox: #{e.message}"
400
+ end
401
+
402
+ # Stops the Sandbox and waits for it to be stopped.
403
+ #
404
+ # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
405
+ # @param force [Boolean] If true, uses SIGKILL instead of SIGTERM (defaults to false).
406
+ # @return [void]
407
+ def stop(timeout = DEFAULT_TIMEOUT, force: false) # rubocop:disable Metrics/MethodLength
408
+ with_timeout(
409
+ timeout:,
410
+ message: "Sandbox #{id} failed to become stopped within the #{timeout} seconds timeout period",
411
+ setup: proc {
412
+ sandbox_api.stop_sandbox(id, { force: force })
413
+ refresh
414
+ }
415
+ ) do
416
+ wait_for_states(
417
+ operation: OPERATION_STOP,
418
+ target_states: [NightonaApiClient::SandboxState::STOPPED, NightonaApiClient::SandboxState::DESTROYED]
419
+ )
420
+ end
421
+ end
422
+
423
+ # Resizes the Sandbox resources.
424
+ #
425
+ # Changes the CPU, memory, or disk allocation. Resizing a started sandbox accepts
426
+ # only CPU and memory increases. Disk resize requires a stopped sandbox; disk can
427
+ # only grow. GPU is not resizable — to change GPU, create a new sandbox.
428
+ #
429
+ # @param resources [Nightona::Resources] New resource configuration (cpu, memory, disk only)
430
+ # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s)
431
+ # @return [void]
432
+ # @raise [Sdk::Error] If resources.gpu or resources.gpu_type is set
433
+ #
434
+ # @example Resize a started sandbox (CPU and memory can be increased)
435
+ # sandbox.resize(Nightona::Resources.new(cpu: 4, memory: 8))
436
+ #
437
+ # @example Resize a stopped sandbox (CPU, memory, and disk can be changed)
438
+ # sandbox.stop
439
+ # sandbox.resize(Nightona::Resources.new(cpu: 2, memory: 4, disk: 30))
440
+ def resize(resources, timeout = DEFAULT_TIMEOUT)
441
+ raise Sdk::Error, 'Resources must not be nil' if resources.nil?
442
+
443
+ if resources.gpu || resources.gpu_type
444
+ raise Sdk::Error,
445
+ 'Resize does not support changes to gpu or gpu_type — to change GPU, create a new sandbox'
446
+ end
447
+
448
+ with_timeout(
449
+ timeout:,
450
+ message: "Sandbox #{id} failed to resize within the #{timeout} seconds timeout period",
451
+ setup: proc {
452
+ resize_attrs = {}
453
+ resize_attrs[:cpu] = resources.cpu if resources.cpu
454
+ resize_attrs[:memory] = resources.memory if resources.memory
455
+ resize_attrs[:disk] = resources.disk if resources.disk
456
+ resize_request = NightonaApiClient::ResizeSandbox.new(resize_attrs)
457
+ process_response(sandbox_api.resize_sandbox(id, resize_request))
458
+ }
459
+ ) { wait_for_resize_complete }
460
+ end
461
+
462
+ # Waits for the Sandbox resize operation to complete.
463
+ # Polls the Sandbox status until the state is no longer 'resizing'.
464
+ #
465
+ # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s)
466
+ # @return [void]
467
+ def wait_for_resize_complete(_timeout = DEFAULT_TIMEOUT)
468
+ wait_for_states(operation: OPERATION_RESIZE, target_states: [NightonaApiClient::SandboxState::STARTED,
469
+ NightonaApiClient::SandboxState::STOPPED])
470
+ end
471
+
472
+ # Creates a new Language Server Protocol (LSP) server instance.
473
+ # The LSP server provides language-specific features like code completion,
474
+ # diagnostics, and more.
475
+ #
476
+ # @param language_id [Symbol] The language server type (e.g., Nightona::LspServer::Language::PYTHON)
477
+ # @param path_to_project [String] Path to the project root directory. Relative paths are resolved
478
+ # based on the sandbox working directory.
479
+ # @return [Nightona::LspServer]
480
+ def create_lsp_server(language_id:, path_to_project:)
481
+ LspServer.new(language_id:, path_to_project:, toolbox_api: @lsp_api, sandbox_id: id, otel_state:)
482
+ end
483
+
484
+ # Validates an SSH access token for the sandbox.
485
+ #
486
+ # @param token [String]
487
+ # @return [NightonaApiClient::SshAccessValidationDto]
488
+ def validate_ssh_access(token) = sandbox_api.validate_ssh_access(token)
489
+
490
+ # Waits for the Sandbox to reach the 'started' state. Polls the Sandbox status until it
491
+ # reaches the 'started' state or encounters an error.
492
+ #
493
+ # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
494
+ # @return [void]
495
+ def wait_for_sandbox_start(_timeout = DEFAULT_TIMEOUT)
496
+ wait_for_states(operation: OPERATION_START, target_states: [NightonaApiClient::SandboxState::STARTED])
497
+ end
498
+
499
+ # Waits for the Sandbox to reach the 'stopped' state. Polls the Sandbox status until it
500
+ # reaches the 'stopped' state or encounters an error.
501
+ # Treats destroyed as stopped to cover ephemeral sandboxes that are automatically deleted after stopping.
502
+ #
503
+ # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
504
+ # @return [void]
505
+ def wait_for_sandbox_stop(_timeout = DEFAULT_TIMEOUT)
506
+ wait_for_states(operation: OPERATION_STOP, target_states: [NightonaApiClient::SandboxState::STOPPED,
507
+ NightonaApiClient::SandboxState::DESTROYED])
508
+ end
509
+
510
+ # Forks the Sandbox, creating a new Sandbox with an identical filesystem.
511
+ # The forked Sandbox is a copy-on-write clone of the original. It starts
512
+ # with the same disk contents but operates independently from that point on.
513
+ #
514
+ # @param name [String, nil] Optional name for the forked Sandbox
515
+ # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s)
516
+ # @return [Nightona::Sandbox] The forked Sandbox
517
+ def experimental_fork(name: nil, timeout: DEFAULT_TIMEOUT) # rubocop:disable Metrics/MethodLength
518
+ forked_dto = nil
519
+ with_timeout(
520
+ timeout:,
521
+ message: "Sandbox #{id} fork failed to become ready within the #{timeout} seconds timeout period",
522
+ setup: proc {
523
+ forked_dto = sandbox_api.fork_sandbox(id, NightonaApiClient::ForkSandbox.new(name:))
524
+ }
525
+ ) do
526
+ forked = Sandbox.new(
527
+ sandbox_dto: forked_dto,
528
+ config:,
529
+ sandbox_api:,
530
+ code_toolbox:,
531
+ otel_state:
532
+ )
533
+ forked.send(:wait_for_states, operation: OPERATION_START,
534
+ target_states: [NightonaApiClient::SandboxState::STARTED])
535
+ return forked
536
+ end
537
+ end
538
+
539
+ # Creates a snapshot from the current state of the Sandbox.
540
+ # The Sandbox will temporarily enter a 'snapshotting' state and return to its previous state when complete.
541
+ #
542
+ # @param name [String] Name for the new snapshot
543
+ # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s)
544
+ # @return [void]
545
+ def experimental_create_snapshot(name:, timeout: DEFAULT_TIMEOUT)
546
+ with_timeout(
547
+ timeout:,
548
+ message: "Sandbox #{id} snapshot failed within the #{timeout} seconds timeout period",
549
+ setup: proc {
550
+ sandbox_api.create_sandbox_snapshot(id, NightonaApiClient::CreateSandboxSnapshot.new(name:))
551
+ refresh
552
+ }
553
+ ) { wait_for_snapshot_complete }
554
+ end
555
+
556
+ # Pauses the Sandbox, freezing all running processes.
557
+ # The Sandbox will enter a 'pausing' state and transition to 'paused' when complete.
558
+ #
559
+ # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s)
560
+ # @return [void]
561
+ def pause(timeout: DEFAULT_TIMEOUT)
562
+ with_timeout(
563
+ timeout:,
564
+ message: "Sandbox #{id} failed to pause within the #{timeout} seconds timeout period",
565
+ setup: proc {
566
+ sandbox_api.pause_sandbox(id)
567
+ refresh
568
+ }
569
+ ) { wait_for_pause_complete }
570
+ end
571
+
572
+ instrument :archive, :auto_archive_interval=, :auto_delete_interval=, :auto_stop_interval=,
573
+ :update_network_settings,
574
+ :create_ssh_access, :delete, :get_user_home_dir, :get_work_dir, :labels=,
575
+ :preview_url, :create_signed_preview_url, :expire_signed_preview_url,
576
+ :refresh, :refresh_activity, :revoke_ssh_access, :start, :recover, :stop,
577
+ :create_lsp_server, :validate_ssh_access, :wait_for_sandbox_start,
578
+ :wait_for_sandbox_stop, :resize, :wait_for_resize_complete,
579
+ :experimental_fork, :experimental_create_snapshot, :pause,
580
+ component: 'Sandbox'
581
+
582
+ private
583
+
584
+ # @return [Nightona::OtelState, nil]
585
+ attr_reader :otel_state
586
+
587
+ # Build toolbox API configuration with dynamic base URL from preview link
588
+ # @return [NightonaToolboxApiClient::Configuration]
589
+ def build_toolbox_api_config
590
+ NightonaToolboxApiClient::Configuration.new.configure do |cfg|
591
+ proxy_url = @toolbox_proxy_url
592
+ proxy_url += '/' unless proxy_url.end_with?('/')
593
+ full_url = "#{proxy_url}#{id}"
594
+ uri = URI(full_url)
595
+
596
+ cfg.scheme = uri.scheme
597
+ cfg.host = uri.authority # Includes hostname:port
598
+ cfg.base_path = uri.path.empty? ? '/' : uri.path
599
+
600
+ cfg
601
+ end
602
+ end
603
+
604
+ # @params sandbox_dto [NightonaApiClient::Sandbox, NightonaApiClient::SandboxListItem]
605
+ # @return [void]
606
+ def process_response(sandbox_dto) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
607
+ # Fields shared by both NightonaApiClient::Sandbox and NightonaApiClient::SandboxListItem.
608
+ @id = sandbox_dto.id
609
+ @organization_id = sandbox_dto.organization_id
610
+ @snapshot = sandbox_dto.snapshot
611
+ @user = sandbox_dto.user
612
+ @labels = sandbox_dto.labels
613
+ @public = sandbox_dto.public
614
+ @target = sandbox_dto.target
615
+ @cpu = sandbox_dto.cpu
616
+ @gpu = sandbox_dto.gpu
617
+ @memory = sandbox_dto.memory
618
+ @disk = sandbox_dto.disk
619
+ @state = sandbox_dto.state
620
+ @desired_state = sandbox_dto.desired_state
621
+ @error_reason = sandbox_dto.error_reason
622
+ @backup_state = sandbox_dto.backup_state
623
+ @auto_stop_interval = sandbox_dto.auto_stop_interval
624
+ @auto_archive_interval = sandbox_dto.auto_archive_interval
625
+ @auto_delete_interval = sandbox_dto.auto_delete_interval
626
+ @created_at = sandbox_dto.created_at
627
+ @updated_at = sandbox_dto.updated_at
628
+ @last_activity_at = sandbox_dto.last_activity_at
629
+ @daemon_version = sandbox_dto.daemon_version
630
+ @toolbox_proxy_url = sandbox_dto.toolbox_proxy_url
631
+
632
+ # Fields only present on the full NightonaApiClient::Sandbox DTO (not returned by list
633
+ # results; call #refresh on each item to populate them).
634
+ return unless sandbox_dto.is_a?(NightonaApiClient::Sandbox)
635
+
636
+ @env = sandbox_dto.env
637
+ @network_block_all = sandbox_dto.network_block_all
638
+ @network_allow_list = sandbox_dto.network_allow_list
639
+ @domain_allow_list = sandbox_dto.domain_allow_list
640
+ @volumes = sandbox_dto.volumes
641
+ @build_info = sandbox_dto.build_info
642
+ @backup_created_at = sandbox_dto.backup_created_at
643
+ end
644
+
645
+ # Monitors block not to exceed max execution time.
646
+ #
647
+ # @param setup [#call, Nil] Optional setup block
648
+ # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s)
649
+ # @param message [String] Error message
650
+ # @return [void]
651
+ # @raise [Nightona::Sdk::Error]
652
+ def with_timeout(message:, setup:, timeout: DEFAULT_TIMEOUT, &)
653
+ start_at = Time.now
654
+ setup&.call
655
+
656
+ Timeout.timeout(
657
+ setup ? [NO_TIMEOUT, timeout - (Time.now - start_at)].max : timeout,
658
+ Sdk::Error,
659
+ message,
660
+ &
661
+ )
662
+ end
663
+
664
+ # Waits for the Sandbox to reach the one of the target states. Polls the Sandbox status until it
665
+ # reaches the one of the target states or encounters an error. It will wait up to 60 seconds
666
+ # for the Sandbox to reach one of the target states.
667
+ #
668
+ # @param operation [#to_s] Operation name for error message
669
+ # @param target_states [Array<NightonaApiClient::SandboxState>] List of the target states
670
+ # @return [void]
671
+ # @raise [Nightona::Sdk::Error]
672
+ def wait_for_states(operation:, target_states:)
673
+ interval = INITIAL_POLL_INTERVAL
674
+ start_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
675
+ loop do
676
+ case state
677
+ when *target_states then return
678
+ when NightonaApiClient::SandboxState::ERROR, NightonaApiClient::SandboxState::BUILD_FAILED
679
+ raise Sdk::Error, "Sandbox #{id} failed to #{operation} with state: #{state}, error reason: #{error_reason}"
680
+ end
681
+
682
+ sleep(interval)
683
+ if ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start_time > 5
684
+ interval = [interval * BACKOFF_MULTIPLIER, MAX_POLL_INTERVAL].min
685
+ end
686
+ refresh
687
+ end
688
+ end
689
+
690
+ INITIAL_POLL_INTERVAL = 0.1
691
+ private_constant :INITIAL_POLL_INTERVAL
692
+
693
+ MAX_POLL_INTERVAL = 1.0
694
+ private_constant :MAX_POLL_INTERVAL
695
+
696
+ BACKOFF_MULTIPLIER = 1.1
697
+ private_constant :BACKOFF_MULTIPLIER
698
+
699
+ NO_TIMEOUT = 0
700
+ private_constant :NO_TIMEOUT
701
+
702
+ OPERATION_START = :start
703
+ private_constant :OPERATION_START
704
+
705
+ OPERATION_STOP = :stop
706
+ private_constant :OPERATION_STOP
707
+
708
+ OPERATION_RESIZE = :resize
709
+ private_constant :OPERATION_RESIZE
710
+
711
+ def wait_for_snapshot_complete
712
+ interval = INITIAL_POLL_INTERVAL
713
+ start_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
714
+ while state == NightonaApiClient::SandboxState::SNAPSHOTTING
715
+ refresh
716
+
717
+ if [NightonaApiClient::SandboxState::ERROR, NightonaApiClient::SandboxState::BUILD_FAILED].include?(state)
718
+ raise Sdk::Error,
719
+ "Sandbox #{id} snapshot failed with state: #{state}, error reason: #{error_reason}"
720
+ end
721
+
722
+ break if state != NightonaApiClient::SandboxState::SNAPSHOTTING
723
+
724
+ sleep(interval)
725
+ if ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start_time > 5
726
+ interval = [interval * BACKOFF_MULTIPLIER, MAX_POLL_INTERVAL].min
727
+ end
728
+ end
729
+ end
730
+
731
+ def wait_for_pause_complete
732
+ interval = INITIAL_POLL_INTERVAL
733
+ start_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
734
+ while state == NightonaApiClient::SandboxState::PAUSING
735
+ refresh
736
+
737
+ if [NightonaApiClient::SandboxState::ERROR, NightonaApiClient::SandboxState::BUILD_FAILED].include?(state)
738
+ raise Sdk::Error,
739
+ "Sandbox #{id} pause failed with state: #{state}, error reason: #{error_reason}"
740
+ end
741
+
742
+ break if state != NightonaApiClient::SandboxState::PAUSING
743
+
744
+ sleep(interval)
745
+ if ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start_time > 5
746
+ interval = [interval * BACKOFF_MULTIPLIER, MAX_POLL_INTERVAL].min
747
+ end
748
+ end
749
+ end
750
+ end
751
+ end