open_sandbox 0.1.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/CHANGELOG.md +14 -0
- data/LICENSE +21 -0
- data/README.md +186 -0
- data/lib/generators/open_sandbox/install_generator.rb +22 -0
- data/lib/generators/open_sandbox/templates/initializer.rb +23 -0
- data/lib/open_sandbox/client.rb +112 -0
- data/lib/open_sandbox/errors.rb +30 -0
- data/lib/open_sandbox/http_client.rb +121 -0
- data/lib/open_sandbox/models.rb +124 -0
- data/lib/open_sandbox/pools.rb +66 -0
- data/lib/open_sandbox/runner.rb +111 -0
- data/lib/open_sandbox/sandboxes.rb +231 -0
- data/lib/open_sandbox/version.rb +5 -0
- data/lib/open_sandbox.rb +5 -0
- metadata +85 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 3ff9458ad74494093739cda38472437e30b4dd0a081b68bf76f91c998b7b032e
|
|
4
|
+
data.tar.gz: 829770a3369b58e83fe541745a0a1765f5c98dbdbf680be37a21a06db4b947b8
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8f7bd0c0f24041a75a2ab5e6ef66ed7ac99d28a3c11317d6777c4dbeec9422a5465a48a75b9dde1042b3c26646e2c7d0f03e0835ded535a4209725c23f561751
|
|
7
|
+
data.tar.gz: 8e6740dd38a16f475b4d6a11a161259e01eebae6419ac06bb4d8905ea9de51213bcdd6c1d7b5570284087e33a8e04c780f27d72f6c7ec0db1c55ca9962024b74
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-04-29
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Initial release
|
|
7
|
+
- `OpenSandbox::Client` with global `configure` block and singleton `OpenSandbox.client`
|
|
8
|
+
- `Sandboxes` resource: `list`, `get`, `create`, `delete`, `pause`, `resume`, `renew_expiration`, `endpoint`, `proxy`, `wait_until`
|
|
9
|
+
- `Sandboxes` diagnostics: `logs`, `inspect_container`, `events`, `diagnostics`
|
|
10
|
+
- `Pools` resource: `list`, `get`, `create`, `update`, `delete`
|
|
11
|
+
- Typed value objects: `Sandbox`, `SandboxStatus`, `Endpoint`, `Pool`, `PoolCapacitySpec`, `SandboxList`, `PaginationInfo`
|
|
12
|
+
- Full error hierarchy: `InvalidRequestError`, `AuthenticationError`, `ForbiddenError`, `NotFoundError`, `ConflictError`, `ValidationError`, `ServerError`, `ConnectionError`
|
|
13
|
+
- Configurable `Logger` (silent by default)
|
|
14
|
+
- Docker timestamp stripping utility (`OpenSandbox::LogUtils.strip_timestamps`)
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Leonx AI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# OpenSandbox
|
|
2
|
+
|
|
3
|
+
Ruby SDK for the [open-sandbox.ai](https://open-sandbox.ai) API — manage isolated container sandboxes for secure code execution.
|
|
4
|
+
|
|
5
|
+
[](https://rubygems.org/gems/open_sandbox)
|
|
6
|
+
[](https://github.com/graysonchen/open_sandbox-sdk-ruby/actions)
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
Add to your Gemfile:
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
gem "open_sandbox", path: "../open_sandbox" # local development
|
|
14
|
+
# or, once published:
|
|
15
|
+
gem "open_sandbox", "~> 0.1"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Rails
|
|
19
|
+
|
|
20
|
+
Generate the initializer in your Rails app:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
rails generate open_sandbox:install
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
This creates `config/initializers/open_sandbox.rb`:
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
OpenSandbox.configure do |config|
|
|
30
|
+
# config.base_url = ENV.fetch("SANDBOX_DOMAIN", "https://api.open-sandbox.ai")
|
|
31
|
+
# config.api_key = ENV.fetch("SANDBOX_API_KEY")
|
|
32
|
+
# config.timeout = 300
|
|
33
|
+
# config.logger = Rails.logger
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Uncomment and adjust the options you need. By default the SDK reads `SANDBOX_DOMAIN` and `SANDBOX_API_KEY` from ENV, so the initializer can stay empty for most setups.
|
|
38
|
+
|
|
39
|
+
## Quick Start
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
require "open_sandbox"
|
|
43
|
+
|
|
44
|
+
# Configure (picks up SANDBOX_DOMAIN / SANDBOX_API_KEY from ENV by default)
|
|
45
|
+
OpenSandbox.configure do |c|
|
|
46
|
+
c.base_url = "http://localhost:8787" # or "https://api.open-sandbox.ai"
|
|
47
|
+
c.api_key = "sk-..." # omit for local dev
|
|
48
|
+
c.logger = Logger.new($stdout, level: :debug)
|
|
49
|
+
end
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
## Runner — one-shot code execution
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
# Python
|
|
57
|
+
result = OpenSandbox::Runner.python("print(2 ** 10)")
|
|
58
|
+
result.output # => "1024\n"
|
|
59
|
+
result.success? # => true
|
|
60
|
+
result.elapsed # => 3.14
|
|
61
|
+
|
|
62
|
+
# Node.js
|
|
63
|
+
result = OpenSandbox::Runner.node("console.log('hi')")
|
|
64
|
+
|
|
65
|
+
# Shell
|
|
66
|
+
result = OpenSandbox::Runner.shell("echo $((6 * 7))")
|
|
67
|
+
|
|
68
|
+
# Custom image
|
|
69
|
+
result = OpenSandbox::Runner.call(
|
|
70
|
+
image: "ubuntu:24.04",
|
|
71
|
+
command: ["bash", "-c", "ls /"],
|
|
72
|
+
timeout: 60
|
|
73
|
+
)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The sandbox is always deleted after the run (even on error).
|
|
77
|
+
`Runner` uses `OpenSandbox.logger` — set it to `Rails.logger` in your initializer to get structured logs.
|
|
78
|
+
|
|
79
|
+
Timeout resolution order: explicit `timeout:` argument → `SANDBOX_TIMEOUT` ENV → `300` s (default).
|
|
80
|
+
|
|
81
|
+
## OpenSandbox client
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
client = OpenSandbox.client
|
|
85
|
+
|
|
86
|
+
# Create a sandbox
|
|
87
|
+
sandbox = client.sandboxes.create(
|
|
88
|
+
image: "python:3.11-slim",
|
|
89
|
+
entrypoint: ["python", "-c", "print(2 ** 10)"],
|
|
90
|
+
timeout: 300,
|
|
91
|
+
resource_limits: { "cpu" => "500m", "memory" => "256Mi" }
|
|
92
|
+
)
|
|
93
|
+
puts sandbox.id # => "c9a03139-..."
|
|
94
|
+
puts sandbox.status.state # => "Running"
|
|
95
|
+
|
|
96
|
+
# Wait until Running
|
|
97
|
+
client.sandboxes.wait_until(sandbox.id, target_state: OpenSandbox::SandboxState::RUNNING)
|
|
98
|
+
|
|
99
|
+
# Get endpoint for a service on port 8080
|
|
100
|
+
ep = client.sandboxes.endpoint(sandbox.id, port: 8080)
|
|
101
|
+
puts ep.url # => "http://sb-xxx.sandbox.local:8080"
|
|
102
|
+
|
|
103
|
+
# Proxy an HTTP request directly to the sandbox
|
|
104
|
+
response = client.sandboxes.proxy(sandbox.id, port: 8080, method: :post,
|
|
105
|
+
path: "/run", body: { code: "print(1)" })
|
|
106
|
+
|
|
107
|
+
# Fetch logs (Docker timestamps stripped automatically)
|
|
108
|
+
puts client.sandboxes.logs(sandbox.id, tail: 100)
|
|
109
|
+
|
|
110
|
+
# Lifecycle management
|
|
111
|
+
client.sandboxes.pause(sandbox.id)
|
|
112
|
+
client.sandboxes.resume(sandbox.id)
|
|
113
|
+
client.sandboxes.renew_expiration(sandbox.id, expires_at: Time.now + 3600)
|
|
114
|
+
client.sandboxes.delete(sandbox.id)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Resource Pools (pre-warm for low cold-start)
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
pool = client.pools.create(
|
|
121
|
+
name: "python-pool",
|
|
122
|
+
template: { spec: { containers: [{ name: "main", image: "python:3.11-slim" }] } },
|
|
123
|
+
capacity_spec: OpenSandbox::PoolCapacitySpec.new(
|
|
124
|
+
buffer_max: 5, buffer_min: 2, pool_max: 20, pool_min: 2
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Use the pool when creating a sandbox
|
|
129
|
+
client.sandboxes.create(
|
|
130
|
+
image: "python:3.11-slim",
|
|
131
|
+
entrypoint: ["python", "app.py"],
|
|
132
|
+
resource_limits: { "cpu" => "1", "memory" => "512Mi" },
|
|
133
|
+
extensions: { "poolRef" => "python-pool" }
|
|
134
|
+
)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Diagnostics
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
puts client.sandboxes.logs(id, tail: 200, since: "10m")
|
|
141
|
+
puts client.sandboxes.events(id, limit: 50)
|
|
142
|
+
puts client.sandboxes.diagnostics(id) # inspect + events + logs combined
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Configuration
|
|
146
|
+
|
|
147
|
+
| Option | ENV var | Default |
|
|
148
|
+
|------------|-------------------|----------------------------|
|
|
149
|
+
| `base_url` | `SANDBOX_DOMAIN` | `http://localhost:8787` |
|
|
150
|
+
| `api_key` | `SANDBOX_API_KEY` | `nil` (optional for local) |
|
|
151
|
+
| `timeout` | `SANDBOX_TIMEOUT` | `300` (seconds) |
|
|
152
|
+
| `logger` | — | `Logger.new(nil)` (silent) |
|
|
153
|
+
|
|
154
|
+
## Error Handling
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
rescue OpenSandbox::NotFoundError => e # 404
|
|
158
|
+
rescue OpenSandbox::AuthenticationError # 401
|
|
159
|
+
rescue OpenSandbox::ForbiddenError # 403
|
|
160
|
+
rescue OpenSandbox::ConflictError # 409
|
|
161
|
+
rescue OpenSandbox::ValidationError # 422
|
|
162
|
+
rescue OpenSandbox::ServerError # 5xx
|
|
163
|
+
rescue OpenSandbox::ConnectionError # network / timeout
|
|
164
|
+
rescue OpenSandbox::Error # catch-all
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Log Utility
|
|
168
|
+
|
|
169
|
+
Strip Docker-style timestamp prefixes from raw log strings:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
raw = "2026-04-29T13:37:33.993Z 1024\n"
|
|
173
|
+
OpenSandbox::LogUtils.strip_timestamps(raw)
|
|
174
|
+
# => "1024\n"
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Development
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
bundle install
|
|
181
|
+
bundle exec rake test # run all tests
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## License
|
|
185
|
+
|
|
186
|
+
MIT
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module OpenSandbox
|
|
6
|
+
module Generators
|
|
7
|
+
# Generates config/initializers/open_sandbox.rb in the host Rails app.
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# rails generate open_sandbox:install
|
|
11
|
+
#
|
|
12
|
+
class InstallGenerator < Rails::Generators::Base
|
|
13
|
+
source_root File.expand_path("templates", __dir__)
|
|
14
|
+
|
|
15
|
+
desc "Creates an OpenSandbox initializer in config/initializers/"
|
|
16
|
+
|
|
17
|
+
def copy_initializer
|
|
18
|
+
template "initializer.rb", "config/initializers/open_sandbox.rb"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open_sandbox"
|
|
4
|
+
|
|
5
|
+
# Configuration reference:
|
|
6
|
+
# base_url — API endpoint (default: ENV["SANDBOX_DOMAIN"] || "http://localhost:8787")
|
|
7
|
+
# api_key — Authentication key (default: ENV["SANDBOX_API_KEY"])
|
|
8
|
+
# timeout — HTTP request timeout in seconds (default: 30)
|
|
9
|
+
# logger — Ruby Logger instance (default: silent)
|
|
10
|
+
#
|
|
11
|
+
OpenSandbox.configure do |config|
|
|
12
|
+
# Set via Rails credentials: Rails.application.credentials.sandbox_domain
|
|
13
|
+
config.base_url = ENV.fetch("SANDBOX_DOMAIN", "http://localhost:8080")
|
|
14
|
+
|
|
15
|
+
# API Key: optional for local dev, required for production
|
|
16
|
+
# Set via Rails credentials: Rails.application.credentials.sandbox_api_key
|
|
17
|
+
config.api_key = ENV.fetch("SANDBOX_API_KEY", nil)
|
|
18
|
+
|
|
19
|
+
# HTTP timeout in seconds
|
|
20
|
+
config.timeout = ENV.fetch("SANDBOX_TIMEOUT", 300).to_i
|
|
21
|
+
|
|
22
|
+
# config.logger = Logger.new($stdout, level: :debug)
|
|
23
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
require_relative "models"
|
|
6
|
+
require_relative "http_client"
|
|
7
|
+
require_relative "sandboxes"
|
|
8
|
+
require_relative "pools"
|
|
9
|
+
|
|
10
|
+
# OpenSandbox Ruby SDK
|
|
11
|
+
#
|
|
12
|
+
# Wraps the open-sandbox.ai REST API for managing isolated container sandboxes.
|
|
13
|
+
#
|
|
14
|
+
# == Quick Start
|
|
15
|
+
#
|
|
16
|
+
# client = OpenSandbox::Client.new
|
|
17
|
+
#
|
|
18
|
+
# sandbox = client.sandboxes.create(
|
|
19
|
+
# image: "python:3.11-slim",
|
|
20
|
+
# entrypoint: ["python", "-c", "print('hello')"],
|
|
21
|
+
# timeout: 300
|
|
22
|
+
# )
|
|
23
|
+
#
|
|
24
|
+
# client.sandboxes.wait_until(sandbox.id, target_state: OpenSandbox::SandboxState::RUNNING)
|
|
25
|
+
# endpoint = client.sandboxes.endpoint(sandbox.id, port: 8080)
|
|
26
|
+
# client.sandboxes.delete(sandbox.id)
|
|
27
|
+
#
|
|
28
|
+
# == Configuration
|
|
29
|
+
#
|
|
30
|
+
# OpenSandbox.configure do |c|
|
|
31
|
+
# c.base_url = "https://api.open-sandbox.ai"
|
|
32
|
+
# c.api_key = "sk-..."
|
|
33
|
+
# c.timeout = 300
|
|
34
|
+
# c.logger = Logger.new($stdout, level: :info)
|
|
35
|
+
# end
|
|
36
|
+
#
|
|
37
|
+
module OpenSandbox
|
|
38
|
+
# Global configuration object
|
|
39
|
+
class Configuration
|
|
40
|
+
attr_accessor :base_url, :api_key, :timeout, :logger
|
|
41
|
+
|
|
42
|
+
def initialize
|
|
43
|
+
@base_url = ENV.fetch("SANDBOX_DOMAIN", "http://localhost:8787")
|
|
44
|
+
@api_key = ENV.fetch("SANDBOX_API_KEY", nil)
|
|
45
|
+
@timeout = ENV.fetch("SANDBOX_TIMEOUT", 300).to_i
|
|
46
|
+
@logger = Logger.new(nil) # silent by default
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class << self
|
|
51
|
+
def configuration
|
|
52
|
+
@configuration ||= Configuration.new
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def configure
|
|
56
|
+
yield configuration
|
|
57
|
+
reset_client!
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Shortcut: OpenSandbox.client (singleton, reset after configure)
|
|
61
|
+
def client
|
|
62
|
+
@client ||= Client.new
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def reset_client!
|
|
66
|
+
@client = nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Convenience delegators
|
|
70
|
+
def logger = configuration.logger
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Main entry point for the OpenSandbox SDK.
|
|
74
|
+
class Client
|
|
75
|
+
# @param base_url [String, nil] override global config
|
|
76
|
+
# @param api_key [String, nil] override global config
|
|
77
|
+
# @param timeout [Integer, nil] override global config
|
|
78
|
+
# @param logger [Logger, nil] override global config
|
|
79
|
+
def initialize(base_url: nil, api_key: nil, timeout: nil, logger: nil)
|
|
80
|
+
cfg = OpenSandbox.configuration
|
|
81
|
+
@base_url = base_url || cfg.base_url
|
|
82
|
+
@api_key = api_key || cfg.api_key
|
|
83
|
+
@timeout = timeout || cfg.timeout
|
|
84
|
+
@logger = logger || cfg.logger
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @return [Sandboxes]
|
|
88
|
+
def sandboxes
|
|
89
|
+
@sandboxes ||= Sandboxes.new(http, logger: @logger)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @return [Pools]
|
|
93
|
+
def pools
|
|
94
|
+
@pools ||= Pools.new(http)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Health check — returns true if the server responds successfully.
|
|
98
|
+
# @return [Boolean]
|
|
99
|
+
def healthy?
|
|
100
|
+
http.get("/health")
|
|
101
|
+
true
|
|
102
|
+
rescue Error
|
|
103
|
+
false
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def http
|
|
109
|
+
@http ||= HttpClient.new(base_url: @base_url, api_key: @api_key, timeout: @timeout)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenSandbox
|
|
4
|
+
# Base error for all OpenSandbox errors
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# 400 - Invalid request
|
|
8
|
+
class InvalidRequestError < Error; end
|
|
9
|
+
|
|
10
|
+
# 401 - Missing or invalid credentials
|
|
11
|
+
class AuthenticationError < Error; end
|
|
12
|
+
|
|
13
|
+
# 403 - Insufficient permissions
|
|
14
|
+
class ForbiddenError < Error; end
|
|
15
|
+
|
|
16
|
+
# 404 - Resource not found
|
|
17
|
+
class NotFoundError < Error; end
|
|
18
|
+
|
|
19
|
+
# 409 - Conflict (e.g., already exists, wrong state)
|
|
20
|
+
class ConflictError < Error; end
|
|
21
|
+
|
|
22
|
+
# 422 - Validation error
|
|
23
|
+
class ValidationError < Error; end
|
|
24
|
+
|
|
25
|
+
# 500 - Server error
|
|
26
|
+
class ServerError < Error; end
|
|
27
|
+
|
|
28
|
+
# Connection / timeout errors
|
|
29
|
+
class ConnectionError < Error; end
|
|
30
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "httparty"
|
|
5
|
+
|
|
6
|
+
module OpenSandbox
|
|
7
|
+
# Low-level HTTP client for the OpenSandbox API.
|
|
8
|
+
# Not intended to be used directly – use Client instead.
|
|
9
|
+
class HttpClient
|
|
10
|
+
include HTTParty
|
|
11
|
+
|
|
12
|
+
DEFAULT_TIMEOUT = 30
|
|
13
|
+
DEFAULT_OPEN_TIMEOUT = 10
|
|
14
|
+
|
|
15
|
+
attr_reader :base_url, :api_key, :timeout
|
|
16
|
+
|
|
17
|
+
def initialize(base_url:, api_key: nil, timeout: DEFAULT_TIMEOUT)
|
|
18
|
+
@base_url = base_url.chomp("/")
|
|
19
|
+
@api_key = api_key
|
|
20
|
+
@timeout = timeout
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def get(path, query: {}, headers: {})
|
|
24
|
+
request(:get, path, query: query.compact, headers: headers)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def post(path, body: {}, headers: {})
|
|
28
|
+
request(:post, path, body: body, headers: headers)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def put(path, body: {}, headers: {})
|
|
32
|
+
request(:put, path, body: body, headers: headers)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def delete(path, headers: {})
|
|
36
|
+
request(:delete, path, headers: headers)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Proxy raw HTTP request to sandbox internal service
|
|
40
|
+
# Returns the raw HTTParty response
|
|
41
|
+
def proxy(method, path, body: nil, headers: {})
|
|
42
|
+
request(method, path, body: body, headers: headers, raw: true)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def request(method, path, query: {}, body: nil, headers: {}, raw: false)
|
|
48
|
+
url = "#{base_url}#{path}"
|
|
49
|
+
options = {
|
|
50
|
+
timeout: timeout,
|
|
51
|
+
open_timeout: DEFAULT_OPEN_TIMEOUT,
|
|
52
|
+
headers: build_headers(headers)
|
|
53
|
+
}
|
|
54
|
+
options[:query] = query if query && !query.empty?
|
|
55
|
+
|
|
56
|
+
if body
|
|
57
|
+
options[:body] = body.to_json
|
|
58
|
+
options[:headers] = (options[:headers] || {}).merge("Content-Type" => "application/json")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
response = self.class.send(method, url, options)
|
|
62
|
+
|
|
63
|
+
return response if raw
|
|
64
|
+
handle_response(response)
|
|
65
|
+
rescue ::Net::OpenTimeout, ::Net::ReadTimeout => e
|
|
66
|
+
raise ConnectionError, "Request timed out: #{e.message}"
|
|
67
|
+
rescue ::SocketError, ::Errno::ECONNREFUSED => e
|
|
68
|
+
raise ConnectionError, "Cannot connect to sandbox server at #{base_url}: #{e.message}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def build_headers(extra = {})
|
|
72
|
+
headers = { "Accept" => "application/json" }
|
|
73
|
+
headers["Authorization"] = "Bearer #{api_key}" if api_key && !api_key.empty?
|
|
74
|
+
headers.merge(extra)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def handle_response(response)
|
|
78
|
+
case response.code
|
|
79
|
+
when 200, 201, 202
|
|
80
|
+
parse_json(response)
|
|
81
|
+
when 204
|
|
82
|
+
nil
|
|
83
|
+
when 400
|
|
84
|
+
raise InvalidRequestError, error_message(response)
|
|
85
|
+
when 401
|
|
86
|
+
raise AuthenticationError, error_message(response)
|
|
87
|
+
when 403
|
|
88
|
+
raise ForbiddenError, error_message(response)
|
|
89
|
+
when 404
|
|
90
|
+
raise NotFoundError, error_message(response)
|
|
91
|
+
when 409
|
|
92
|
+
raise ConflictError, error_message(response)
|
|
93
|
+
when 422
|
|
94
|
+
raise ValidationError, error_message(response)
|
|
95
|
+
when 500..599
|
|
96
|
+
raise ServerError, "Server error (#{response.code}): #{error_message(response)}"
|
|
97
|
+
else
|
|
98
|
+
raise Error, "Unexpected status #{response.code}: #{response.body}"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def parse_json(response)
|
|
103
|
+
body = response.body
|
|
104
|
+
return nil if body.nil? || body.empty?
|
|
105
|
+
JSON.parse(body)
|
|
106
|
+
rescue JSON::ParserError
|
|
107
|
+
response.body
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def error_message(response)
|
|
111
|
+
data = parse_json(response)
|
|
112
|
+
if data.is_a?(Hash)
|
|
113
|
+
data["message"] || data["detail"] || response.body
|
|
114
|
+
else
|
|
115
|
+
response.body
|
|
116
|
+
end
|
|
117
|
+
rescue
|
|
118
|
+
response.body
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module OpenSandbox
|
|
6
|
+
# Sandbox lifecycle states
|
|
7
|
+
module SandboxState
|
|
8
|
+
PENDING = "Pending"
|
|
9
|
+
RUNNING = "Running"
|
|
10
|
+
PAUSING = "Pausing"
|
|
11
|
+
PAUSED = "Paused"
|
|
12
|
+
STOPPING = "Stopping"
|
|
13
|
+
TERMINATED = "Terminated"
|
|
14
|
+
FAILED = "Failed"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Value object for sandbox status
|
|
18
|
+
SandboxStatus = Data.define(:state, :reason, :message, :last_transition_at) do
|
|
19
|
+
def running? = state == SandboxState::RUNNING
|
|
20
|
+
def pending? = state == SandboxState::PENDING
|
|
21
|
+
def paused? = state == SandboxState::PAUSED
|
|
22
|
+
def failed? = state == SandboxState::FAILED
|
|
23
|
+
def terminated? = state == SandboxState::TERMINATED
|
|
24
|
+
|
|
25
|
+
def self.from_hash(h)
|
|
26
|
+
return nil unless h
|
|
27
|
+
new(
|
|
28
|
+
state: h["state"],
|
|
29
|
+
reason: h["reason"],
|
|
30
|
+
message: h["message"],
|
|
31
|
+
last_transition_at: h["lastTransitionAt"] ? Time.parse(h["lastTransitionAt"]) : nil
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Value object for a Sandbox resource
|
|
37
|
+
Sandbox = Data.define(:id, :status, :image_uri, :entrypoint, :metadata, :expires_at, :created_at) do
|
|
38
|
+
def self.from_hash(h)
|
|
39
|
+
new(
|
|
40
|
+
id: h["id"],
|
|
41
|
+
status: SandboxStatus.from_hash(h["status"]),
|
|
42
|
+
image_uri: h.dig("image", "uri"),
|
|
43
|
+
entrypoint: h["entrypoint"] || [],
|
|
44
|
+
metadata: h["metadata"] || {},
|
|
45
|
+
expires_at: h["expiresAt"] ? Time.parse(h["expiresAt"]) : nil,
|
|
46
|
+
created_at: h["createdAt"] ? Time.parse(h["createdAt"]) : nil
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Value object for an Endpoint
|
|
52
|
+
Endpoint = Data.define(:endpoint, :headers) do
|
|
53
|
+
def self.from_hash(h)
|
|
54
|
+
new(endpoint: h["endpoint"], headers: h["headers"] || {})
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def url = endpoint.start_with?("http") ? endpoint : "http://#{endpoint}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Value object for pagination
|
|
61
|
+
PaginationInfo = Data.define(:page, :page_size, :total_items, :total_pages, :has_next_page) do
|
|
62
|
+
def self.from_hash(h)
|
|
63
|
+
new(
|
|
64
|
+
page: h["page"],
|
|
65
|
+
page_size: h["pageSize"],
|
|
66
|
+
total_items: h["totalItems"],
|
|
67
|
+
total_pages: h["totalPages"],
|
|
68
|
+
has_next_page: h["hasNextPage"]
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Value object for list response
|
|
74
|
+
SandboxList = Data.define(:items, :pagination) do
|
|
75
|
+
def self.from_hash(h)
|
|
76
|
+
new(
|
|
77
|
+
items: (h["items"] || []).map { Sandbox.from_hash(_1) },
|
|
78
|
+
pagination: PaginationInfo.from_hash(h["pagination"])
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Value object for pool capacity
|
|
84
|
+
PoolCapacitySpec = Data.define(:buffer_max, :buffer_min, :pool_max, :pool_min) do
|
|
85
|
+
def self.from_hash(h)
|
|
86
|
+
new(
|
|
87
|
+
buffer_max: h["bufferMax"],
|
|
88
|
+
buffer_min: h["bufferMin"],
|
|
89
|
+
pool_max: h["poolMax"],
|
|
90
|
+
pool_min: h["poolMin"]
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def to_api_hash
|
|
95
|
+
{ "bufferMax" => buffer_max, "bufferMin" => buffer_min, "poolMax" => pool_max, "poolMin" => pool_min }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Value object for a Pool resource
|
|
100
|
+
Pool = Data.define(:name, :capacity_spec, :status, :created_at) do
|
|
101
|
+
def self.from_hash(h)
|
|
102
|
+
new(
|
|
103
|
+
name: h["name"],
|
|
104
|
+
capacity_spec: PoolCapacitySpec.from_hash(h["capacitySpec"]),
|
|
105
|
+
status: h["status"],
|
|
106
|
+
created_at: h["createdAt"] ? Time.parse(h["createdAt"]) : nil
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Utility helpers for processing sandbox output.
|
|
112
|
+
module LogUtils
|
|
113
|
+
# Strip Docker-style RFC3339 nanosecond timestamp prefixes from each log line.
|
|
114
|
+
#
|
|
115
|
+
# "2026-04-29T13:37:33.993340334Z 1024\n" => "1024\n"
|
|
116
|
+
# "no-timestamp\n" => "no-timestamp\n"
|
|
117
|
+
#
|
|
118
|
+
TIMESTAMP_RE = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z /.freeze
|
|
119
|
+
|
|
120
|
+
def self.strip_timestamps(raw)
|
|
121
|
+
raw.to_s.lines.map { |line| line.sub(TIMESTAMP_RE, "") }.join
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenSandbox
|
|
4
|
+
# Manages pre-warmed resource pools for reduced sandbox cold-start latency.
|
|
5
|
+
class Pools
|
|
6
|
+
def initialize(http)
|
|
7
|
+
@http = http
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# List all pools.
|
|
11
|
+
#
|
|
12
|
+
# @return [Array<Pool>]
|
|
13
|
+
def list
|
|
14
|
+
data = @http.get("/v1/pools")
|
|
15
|
+
(data["items"] || []).map { Pool.from_hash(_1) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Get a pool by name.
|
|
19
|
+
#
|
|
20
|
+
# @param pool_name [String]
|
|
21
|
+
# @return [Pool]
|
|
22
|
+
# @raise [NotFoundError]
|
|
23
|
+
def get(pool_name)
|
|
24
|
+
Pool.from_hash(@http.get("/v1/pools/#{pool_name}"))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Create a new pre-warmed pool.
|
|
28
|
+
#
|
|
29
|
+
# @param name [String] unique pool name (lowercase alphanumeric + hyphens)
|
|
30
|
+
# @param template [Hash] Kubernetes PodTemplateSpec
|
|
31
|
+
# @param capacity_spec [PoolCapacitySpec, Hash] capacity configuration
|
|
32
|
+
# @return [Pool]
|
|
33
|
+
def create(name:, template:, capacity_spec:)
|
|
34
|
+
spec = capacity_spec.is_a?(PoolCapacitySpec) ? capacity_spec.to_api_hash : capacity_spec.transform_keys(&:to_s).then do |h|
|
|
35
|
+
{
|
|
36
|
+
"bufferMax" => h["buffer_max"] || h["bufferMax"],
|
|
37
|
+
"bufferMin" => h["buffer_min"] || h["bufferMin"],
|
|
38
|
+
"poolMax" => h["pool_max"] || h["poolMax"],
|
|
39
|
+
"poolMin" => h["pool_min"] || h["poolMin"]
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
body = { "name" => name.to_s, "template" => template, "capacitySpec" => spec }
|
|
44
|
+
Pool.from_hash(@http.post("/v1/pools", body: body))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Update pool capacity configuration.
|
|
48
|
+
#
|
|
49
|
+
# @param pool_name [String]
|
|
50
|
+
# @param capacity_spec [PoolCapacitySpec, Hash]
|
|
51
|
+
# @return [Pool]
|
|
52
|
+
def update(pool_name, capacity_spec:)
|
|
53
|
+
spec = capacity_spec.is_a?(PoolCapacitySpec) ? capacity_spec.to_api_hash : capacity_spec
|
|
54
|
+
body = { "capacitySpec" => spec }
|
|
55
|
+
Pool.from_hash(@http.put("/v1/pools/#{pool_name}", body: body))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Delete a pool.
|
|
59
|
+
#
|
|
60
|
+
# @param pool_name [String]
|
|
61
|
+
# @return [nil]
|
|
62
|
+
def delete(pool_name)
|
|
63
|
+
@http.delete("/v1/pools/#{pool_name}")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenSandbox
|
|
4
|
+
# Runner: execute code or commands in an ephemeral sandbox and collect output.
|
|
5
|
+
#
|
|
6
|
+
# Opinionated wrapper around OpenSandbox::Client for short-lived,
|
|
7
|
+
# fire-and-collect code execution. The sandbox is always cleaned up after use.
|
|
8
|
+
#
|
|
9
|
+
# == Examples
|
|
10
|
+
# result = SandboxRunnerService.run_python(<<~CODE)
|
|
11
|
+
# import math
|
|
12
|
+
# print(math.sqrt(144))
|
|
13
|
+
# CODE
|
|
14
|
+
# result.output # => "12.0\n"
|
|
15
|
+
#
|
|
16
|
+
# result = OpenSandbox::Runner.python("print(2 ** 10)")
|
|
17
|
+
# result.output # => "1024\n"
|
|
18
|
+
# result.success? # => true
|
|
19
|
+
# result.elapsed # => 3.14
|
|
20
|
+
#
|
|
21
|
+
# result = OpenSandbox::Runner.node("console.log('hi')")
|
|
22
|
+
# result = OpenSandbox::Runner.shell("echo $((6 * 7))")
|
|
23
|
+
#
|
|
24
|
+
# result = OpenSandbox::Runner.call(
|
|
25
|
+
# image: "ubuntu:24.04",
|
|
26
|
+
# command: ["bash", "-c", "ls /"],
|
|
27
|
+
# timeout: 60
|
|
28
|
+
# )
|
|
29
|
+
#
|
|
30
|
+
class Runner
|
|
31
|
+
Result = Data.define(:success, :output, :sandbox_id, :elapsed) do
|
|
32
|
+
alias_method :success?, :success
|
|
33
|
+
def failure? = !success
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
DEFAULT_LIMITS = { "cpu" => "500m", "memory" => "256Mi" }.freeze
|
|
37
|
+
STARTUP_TIMEOUT = 60 # seconds to wait for sandbox to reach Running
|
|
38
|
+
|
|
39
|
+
# ── Convenience shortcuts ─────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
def self.python(code, version: "3.11", timeout: nil)
|
|
42
|
+
call(image: "python:#{version}-slim", command: ["python", "-c", code], timeout: timeout)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.node(code, version: "20", timeout: nil)
|
|
46
|
+
call(image: "node:#{version}-slim", command: ["node", "-e", code], timeout: timeout)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.shell(command, timeout: nil)
|
|
50
|
+
call(image: "ubuntu:24.04", command: ["bash", "-c", command], timeout: timeout)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.call(**kwargs)
|
|
54
|
+
new(**kwargs).call
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# ── Instance ──────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
def initialize(
|
|
60
|
+
image:,
|
|
61
|
+
command:,
|
|
62
|
+
env: {},
|
|
63
|
+
timeout: nil,
|
|
64
|
+
resource_limits: DEFAULT_LIMITS,
|
|
65
|
+
metadata: {}
|
|
66
|
+
)
|
|
67
|
+
@image = image
|
|
68
|
+
@command = command
|
|
69
|
+
@env = env
|
|
70
|
+
@timeout = timeout || OpenSandbox.configuration.timeout
|
|
71
|
+
@resource_limits = resource_limits
|
|
72
|
+
@metadata = metadata
|
|
73
|
+
@client = OpenSandbox.client
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def call
|
|
77
|
+
started_at = Time.now
|
|
78
|
+
sandbox_id = nil
|
|
79
|
+
|
|
80
|
+
sandbox = @client.sandboxes.create(
|
|
81
|
+
image: @image,
|
|
82
|
+
entrypoint: @command,
|
|
83
|
+
env: @env,
|
|
84
|
+
timeout: @timeout,
|
|
85
|
+
resource_limits: @resource_limits,
|
|
86
|
+
metadata: @metadata
|
|
87
|
+
)
|
|
88
|
+
sandbox_id = sandbox.id
|
|
89
|
+
|
|
90
|
+
@client.sandboxes.wait_until(
|
|
91
|
+
sandbox_id,
|
|
92
|
+
target_state: SandboxState::TERMINATED,
|
|
93
|
+
timeout: STARTUP_TIMEOUT + @timeout
|
|
94
|
+
) { |s| OpenSandbox.logger.debug("[Runner] #{sandbox_id} state=#{s.status.state}") }
|
|
95
|
+
|
|
96
|
+
raw = @client.sandboxes.logs(sandbox_id, tail: 1000)
|
|
97
|
+
output = LogUtils.strip_timestamps(raw.to_s)
|
|
98
|
+
|
|
99
|
+
Result.new(success: true, output: output, sandbox_id: sandbox_id, elapsed: Time.now - started_at)
|
|
100
|
+
rescue OpenSandbox::Error => e
|
|
101
|
+
OpenSandbox.logger.error("[Runner] #{sandbox_id}: #{e.message}")
|
|
102
|
+
Result.new(success: false, output: "Error: #{e.message}", sandbox_id: sandbox_id, elapsed: Time.now - started_at)
|
|
103
|
+
ensure
|
|
104
|
+
begin
|
|
105
|
+
@client.sandboxes.delete(sandbox_id) if sandbox_id
|
|
106
|
+
rescue OpenSandbox::Error => e
|
|
107
|
+
OpenSandbox.logger.warn("[Runner] cleanup failed for #{sandbox_id}: #{e.message}")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenSandbox
|
|
4
|
+
# Manages sandbox lifecycle operations.
|
|
5
|
+
#
|
|
6
|
+
# All methods return typed value objects (Sandbox, Endpoint, etc.)
|
|
7
|
+
# or raise OpenSandbox::Error subclasses on failure.
|
|
8
|
+
class Sandboxes
|
|
9
|
+
def initialize(http, logger: Logger.new(nil))
|
|
10
|
+
@http = http
|
|
11
|
+
@logger = logger
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# List all sandboxes with optional filters.
|
|
15
|
+
#
|
|
16
|
+
# @param page [Integer] page number (default 1)
|
|
17
|
+
# @param page_size [Integer] items per page (default 20)
|
|
18
|
+
# @param metadata [Hash] filter by metadata key-value pairs
|
|
19
|
+
# @return [SandboxList]
|
|
20
|
+
def list(page: 1, page_size: 20, metadata: {})
|
|
21
|
+
query = { page: page, pageSize: page_size }
|
|
22
|
+
metadata.each { |k, v| query["metadata.#{k}"] = v }
|
|
23
|
+
data = @http.get("/v1/sandboxes", query: query)
|
|
24
|
+
SandboxList.from_hash(data)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Get a sandbox by ID.
|
|
28
|
+
#
|
|
29
|
+
# @param sandbox_id [String]
|
|
30
|
+
# @return [Sandbox]
|
|
31
|
+
# @raise [NotFoundError]
|
|
32
|
+
def get(sandbox_id)
|
|
33
|
+
data = @http.get("/v1/sandboxes/#{sandbox_id}")
|
|
34
|
+
Sandbox.from_hash(data)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Create a new sandbox.
|
|
38
|
+
#
|
|
39
|
+
# @param image [String] container image URI, e.g. "python:3.11"
|
|
40
|
+
# @param entrypoint [Array<String>] command to run, e.g. ["python", "/app/main.py"]
|
|
41
|
+
# @param resource_limits [Hash] CPU/memory limits, e.g. { cpu: "500m", memory: "512Mi" }
|
|
42
|
+
# @param timeout [Integer, nil] seconds before auto-terminate (min 60); nil = no auto-terminate
|
|
43
|
+
# @param env [Hash] environment variables
|
|
44
|
+
# @param metadata [Hash] custom key-value labels
|
|
45
|
+
# @param network_policy [Hash, nil] egress policy
|
|
46
|
+
# @param volumes [Array<Hash>, nil] volume mounts
|
|
47
|
+
# @param image_auth [Hash, nil] registry credentials { username:, password: }
|
|
48
|
+
# @param extensions [Hash, nil] provider-specific parameters (e.g. poolRef)
|
|
49
|
+
# @param platform [Hash, nil] { os: "linux", arch: "amd64" }
|
|
50
|
+
# @return [Sandbox]
|
|
51
|
+
def create(
|
|
52
|
+
image:,
|
|
53
|
+
entrypoint:,
|
|
54
|
+
resource_limits: { "cpu" => "500m", "memory" => "512Mi" },
|
|
55
|
+
timeout: 300,
|
|
56
|
+
env: {},
|
|
57
|
+
metadata: {},
|
|
58
|
+
network_policy: nil,
|
|
59
|
+
volumes: nil,
|
|
60
|
+
image_auth: nil,
|
|
61
|
+
extensions: nil,
|
|
62
|
+
platform: nil
|
|
63
|
+
)
|
|
64
|
+
body = {
|
|
65
|
+
image: build_image_spec(image, image_auth),
|
|
66
|
+
entrypoint: entrypoint,
|
|
67
|
+
resourceLimits: resource_limits.transform_keys(&:to_s),
|
|
68
|
+
env: env.transform_keys(&:to_s)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
body[:timeout] = timeout if timeout
|
|
72
|
+
body[:metadata] = metadata.transform_keys(&:to_s) if metadata && !metadata.empty?
|
|
73
|
+
body[:networkPolicy] = network_policy if network_policy
|
|
74
|
+
body[:volumes] = volumes if volumes
|
|
75
|
+
body[:extensions] = extensions.transform_keys(&:to_s) if extensions
|
|
76
|
+
body[:platform] = { "os" => platform[:os], "arch" => platform[:arch] } if platform
|
|
77
|
+
|
|
78
|
+
data = @http.post("/v1/sandboxes", body: body)
|
|
79
|
+
Sandbox.from_hash(data)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Delete (terminate) a sandbox.
|
|
83
|
+
#
|
|
84
|
+
# @param sandbox_id [String]
|
|
85
|
+
# @return [nil]
|
|
86
|
+
def delete(sandbox_id)
|
|
87
|
+
@http.delete("/v1/sandboxes/#{sandbox_id}")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Pause a running sandbox (preserves state).
|
|
91
|
+
#
|
|
92
|
+
# @param sandbox_id [String]
|
|
93
|
+
# @return [nil]
|
|
94
|
+
def pause(sandbox_id)
|
|
95
|
+
@http.post("/v1/sandboxes/#{sandbox_id}/pause")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Resume a paused sandbox.
|
|
99
|
+
#
|
|
100
|
+
# @param sandbox_id [String]
|
|
101
|
+
# @return [nil]
|
|
102
|
+
def resume(sandbox_id)
|
|
103
|
+
@http.post("/v1/sandboxes/#{sandbox_id}/resume")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Renew sandbox expiration time.
|
|
107
|
+
#
|
|
108
|
+
# @param sandbox_id [String]
|
|
109
|
+
# @param expires_at [Time] new expiration time (must be in the future)
|
|
110
|
+
# @return [Time] new expiration time
|
|
111
|
+
def renew_expiration(sandbox_id, expires_at:)
|
|
112
|
+
body = { "expiresAt" => expires_at.utc.iso8601 }
|
|
113
|
+
data = @http.post("/v1/sandboxes/#{sandbox_id}/renew-expiration", body: body)
|
|
114
|
+
Time.parse(data["expiresAt"])
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Get public endpoint URL for a service port inside the sandbox.
|
|
118
|
+
#
|
|
119
|
+
# @param sandbox_id [String]
|
|
120
|
+
# @param port [Integer] port number where the service listens
|
|
121
|
+
# @param use_server_proxy [Boolean] return server-proxied URL
|
|
122
|
+
# @return [Endpoint]
|
|
123
|
+
def endpoint(sandbox_id, port:, use_server_proxy: false)
|
|
124
|
+
data = @http.get(
|
|
125
|
+
"/v1/sandboxes/#{sandbox_id}/endpoints/#{port}",
|
|
126
|
+
query: { useServerProxy: use_server_proxy }
|
|
127
|
+
)
|
|
128
|
+
Endpoint.from_hash(data)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Proxy an HTTP request to a service running inside the sandbox.
|
|
132
|
+
#
|
|
133
|
+
# @param sandbox_id [String]
|
|
134
|
+
# @param port [Integer]
|
|
135
|
+
# @param method [Symbol] :get, :post, :put, :patch, :delete
|
|
136
|
+
# @param path [String] path within the proxied service (default "/")
|
|
137
|
+
# @param body [Hash, nil] request body
|
|
138
|
+
# @param headers [Hash] additional headers
|
|
139
|
+
# @return [HTTParty::Response] raw response
|
|
140
|
+
def proxy(sandbox_id, port:, method: :get, path: "/", body: nil, headers: {})
|
|
141
|
+
proxy_path = path == "/" || path.empty? \
|
|
142
|
+
? "/v1/sandboxes/#{sandbox_id}/proxy/#{port}" \
|
|
143
|
+
: "/v1/sandboxes/#{sandbox_id}/proxy/#{port}/#{path.delete_prefix('/')}"
|
|
144
|
+
|
|
145
|
+
@http.proxy(method, proxy_path, body: body, headers: headers)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# ── Diagnostics ─────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
# Get container logs for a sandbox.
|
|
151
|
+
#
|
|
152
|
+
# @param sandbox_id [String]
|
|
153
|
+
# @param tail [Integer] number of trailing lines
|
|
154
|
+
# @param since [String, nil] duration string, e.g. "10m", "1h"
|
|
155
|
+
# @return [String]
|
|
156
|
+
def logs(sandbox_id, tail: 100, since: nil)
|
|
157
|
+
query = { tail: tail }
|
|
158
|
+
query[:since] = since if since
|
|
159
|
+
@http.get("/v1/sandboxes/#{sandbox_id}/diagnostics/logs", query: query)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Get detailed container inspection info.
|
|
163
|
+
#
|
|
164
|
+
# @param sandbox_id [String]
|
|
165
|
+
# @return [String]
|
|
166
|
+
def inspect_container(sandbox_id)
|
|
167
|
+
@http.get("/v1/sandboxes/#{sandbox_id}/diagnostics/inspect")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Get events for a sandbox.
|
|
171
|
+
#
|
|
172
|
+
# @param sandbox_id [String]
|
|
173
|
+
# @param limit [Integer]
|
|
174
|
+
# @return [String]
|
|
175
|
+
def events(sandbox_id, limit: 50)
|
|
176
|
+
@http.get("/v1/sandboxes/#{sandbox_id}/diagnostics/events", query: { limit: limit })
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Get combined diagnostics summary (inspect + events + logs).
|
|
180
|
+
#
|
|
181
|
+
# @param sandbox_id [String]
|
|
182
|
+
# @param tail [Integer]
|
|
183
|
+
# @param event_limit [Integer]
|
|
184
|
+
# @return [String]
|
|
185
|
+
def diagnostics(sandbox_id, tail: 50, event_limit: 20)
|
|
186
|
+
@http.get(
|
|
187
|
+
"/v1/sandboxes/#{sandbox_id}/diagnostics/summary",
|
|
188
|
+
query: { tail: tail, eventLimit: event_limit }
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# ── Polling helpers ──────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
# Wait until sandbox reaches a target state (or fails/terminates).
|
|
195
|
+
#
|
|
196
|
+
# @param sandbox_id [String]
|
|
197
|
+
# @param target_state [String] e.g. SandboxState::RUNNING
|
|
198
|
+
# @param timeout [Integer] max seconds to wait
|
|
199
|
+
# @param interval [Numeric] polling interval in seconds
|
|
200
|
+
# @yield [Sandbox] called after each poll (optional)
|
|
201
|
+
# @return [Sandbox] when target state reached
|
|
202
|
+
# @raise [Error] if sandbox fails or timeout exceeded
|
|
203
|
+
def wait_until(sandbox_id, target_state:, timeout: 120, interval: 2)
|
|
204
|
+
deadline = Time.now + timeout
|
|
205
|
+
loop do
|
|
206
|
+
sandbox = get(sandbox_id)
|
|
207
|
+
yield sandbox if block_given?
|
|
208
|
+
|
|
209
|
+
return sandbox if sandbox.status.state == target_state
|
|
210
|
+
|
|
211
|
+
if sandbox.status.failed? || sandbox.status.terminated?
|
|
212
|
+
raise Error, "Sandbox #{sandbox_id} entered #{sandbox.status.state} state: #{sandbox.status.message}"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
raise Error, "Timed out waiting for sandbox #{sandbox_id} to reach #{target_state}" if Time.now >= deadline
|
|
216
|
+
|
|
217
|
+
sleep interval
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
private
|
|
222
|
+
|
|
223
|
+
def build_image_spec(image, auth)
|
|
224
|
+
spec = { "uri" => image }
|
|
225
|
+
if auth
|
|
226
|
+
spec["auth"] = { "username" => auth[:username].to_s, "password" => auth[:password].to_s }
|
|
227
|
+
end
|
|
228
|
+
spec
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
data/lib/open_sandbox.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: open_sandbox
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Grayson Chen
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-04-29 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: httparty
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '0.21'
|
|
20
|
+
- - "<"
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: '1'
|
|
23
|
+
type: :runtime
|
|
24
|
+
prerelease: false
|
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
26
|
+
requirements:
|
|
27
|
+
- - ">="
|
|
28
|
+
- !ruby/object:Gem::Version
|
|
29
|
+
version: '0.21'
|
|
30
|
+
- - "<"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '1'
|
|
33
|
+
description: A lightweight, idiomatic Ruby client for managing isolated container
|
|
34
|
+
sandboxes via the open-sandbox.ai REST API. Supports sandbox lifecycle (create,
|
|
35
|
+
pause, resume, delete), resource pools, diagnostics, HTTP proxying, and polling
|
|
36
|
+
helpers.
|
|
37
|
+
email:
|
|
38
|
+
- cgg5207@sina.com
|
|
39
|
+
executables: []
|
|
40
|
+
extensions: []
|
|
41
|
+
extra_rdoc_files: []
|
|
42
|
+
files:
|
|
43
|
+
- CHANGELOG.md
|
|
44
|
+
- LICENSE
|
|
45
|
+
- README.md
|
|
46
|
+
- lib/generators/open_sandbox/install_generator.rb
|
|
47
|
+
- lib/generators/open_sandbox/templates/initializer.rb
|
|
48
|
+
- lib/open_sandbox.rb
|
|
49
|
+
- lib/open_sandbox/client.rb
|
|
50
|
+
- lib/open_sandbox/errors.rb
|
|
51
|
+
- lib/open_sandbox/http_client.rb
|
|
52
|
+
- lib/open_sandbox/models.rb
|
|
53
|
+
- lib/open_sandbox/pools.rb
|
|
54
|
+
- lib/open_sandbox/runner.rb
|
|
55
|
+
- lib/open_sandbox/sandboxes.rb
|
|
56
|
+
- lib/open_sandbox/version.rb
|
|
57
|
+
homepage: https://github.com/graysonchen/open_sandbox-sdk-ruby
|
|
58
|
+
licenses:
|
|
59
|
+
- MIT
|
|
60
|
+
metadata:
|
|
61
|
+
homepage_uri: https://github.com/graysonchen/open_sandbox-sdk-ruby
|
|
62
|
+
source_code_uri: https://github.com/graysonchen/open_sandbox-sdk-ruby
|
|
63
|
+
changelog_uri: https://github.com/graysonchen/open_sandbox-sdk-ruby/blob/main/CHANGELOG.md
|
|
64
|
+
bug_tracker_uri: https://github.com/graysonchen/open_sandbox-sdk-ruby/issues
|
|
65
|
+
rubygems_mfa_required: 'true'
|
|
66
|
+
post_install_message:
|
|
67
|
+
rdoc_options: []
|
|
68
|
+
require_paths:
|
|
69
|
+
- lib
|
|
70
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: 3.1.0
|
|
75
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
76
|
+
requirements:
|
|
77
|
+
- - ">="
|
|
78
|
+
- !ruby/object:Gem::Version
|
|
79
|
+
version: '0'
|
|
80
|
+
requirements: []
|
|
81
|
+
rubygems_version: 3.5.22
|
|
82
|
+
signing_key:
|
|
83
|
+
specification_version: 4
|
|
84
|
+
summary: Ruby SDK for the open-sandbox.ai API
|
|
85
|
+
test_files: []
|