Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/sandboxes.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ when supplied by the server.

## Keep a session

`session.expires_at` is the time the session ends, or `nil` when it has no lifetime limit (a local-mode session), so check for `nil` before using it as a time.

```ruby
client.sandboxes.create(
project_id, region: 'us-east-1', preset: 'python3.12', max_duration_seconds: 300
Expand Down Expand Up @@ -83,7 +85,7 @@ template's configured memory.
Only trusted backend code should call
`client.sandboxes.grant(session_id, auth_user_id, expires_at: expiry)` or
`client.sandboxes.revoke(session_id, auth_user_id)`. Supply a `Time` or ISO8601
string for `expiry`; returned session and access expiries are `Time` values. Project users can access only
string for `expiry`; a returned access expiry is a `Time`, and a session expiry is a `Time` or `nil` when the session has no lifetime limit. Project users can access only
sessions explicitly granted to them; they cannot create or manage sessions.
Service keys stay on the backend. The preset catalog is public; anonymous keys cannot create or manage sessions.

Expand Down
5 changes: 5 additions & 0 deletions lib/volcano/sandbox_response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ def self.timestamp(value)
raise TypeError, 'Invalid Sandbox timestamp'
end

# A session with no lifetime limit, such as a local-mode session, has no expiry.
def self.nullable_timestamp(value)
value.nil? ? nil : timestamp(value)
end

def self.integer(value)
return value if value.is_a?(Integer)

Expand Down
2 changes: 1 addition & 1 deletion lib/volcano/sandbox_session.rb
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ def update(operation, status: 200)

def assign_state(data)
next_state = SandboxResponse.state(data['state'])
expiry = SandboxResponse.timestamp(data['expires_at'])
expiry = SandboxResponse.nullable_timestamp(data['expires_at'])
@state = next_state
@expires_at = expiry
end
Expand Down
7 changes: 3 additions & 4 deletions lib/volcano/sandboxes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,11 @@ def inspect = '#<Volcano::Sandboxes>'

def create( # rubocop:disable Metrics/ParameterLists -- Preserve explicit typed facade keywords.
project_id, region:, preset: nil, sandbox_id: nil, memory_mb: nil,
max_duration_seconds: nil, idle_timeout_seconds: nil, request_id: nil
max_duration_seconds: nil, request_id: nil
)
options = { region: region, preset: preset, sandbox_id: sandbox_id, memory_mb: memory_mb,
max_duration_seconds: max_duration_seconds, idle_timeout_seconds: idle_timeout_seconds,
request_id: request_id }.compact
body = SandboxRequests.selector(options).merge(options.slice(:max_duration_seconds, :idle_timeout_seconds))
max_duration_seconds: max_duration_seconds, request_id: request_id }.compact
body = SandboxRequests.selector(options).merge(options.slice(:max_duration_seconds))
request = SandboxRequest.new(operation: :create_sandbox_session,
resource_id: SandboxRequests.identifier(project_id), body: body,
request_id: SandboxRequests.request_id(options))
Expand Down
6 changes: 3 additions & 3 deletions sig/sandboxes.rbs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module Volcano
type sandbox_selector = { region: String, ?preset: String, ?sandbox_id: String, ?memory_mb: Integer }
type sandbox_create_options = { region: String, ?preset: String, ?sandbox_id: String, ?memory_mb: Integer, ?max_duration_seconds: Integer, ?idle_timeout_seconds: Integer, ?request_id: String }
type sandbox_create_options = { region: String, ?preset: String, ?sandbox_id: String, ?memory_mb: Integer, ?max_duration_seconds: Integer, ?request_id: String }
type sandbox_exec_options = { region: String, ?preset: String, ?sandbox_id: String, ?memory_mb: Integer, ?timeout_seconds: Integer, ?environment: Hash[String, String], ?request_id: String }
type sandbox_command_options = { ?timeout_seconds: Integer, ?environment: Hash[String, String], ?request_id: String }

Expand Down Expand Up @@ -93,7 +93,7 @@ module Volcano
include SandboxDeployments
def inspect: () -> String
def presets: () -> Array[SandboxPreset]
def create: (String project_id, region: String, ?preset: String?, ?sandbox_id: String?, ?memory_mb: Integer?, ?max_duration_seconds: Integer?, ?idle_timeout_seconds: Integer?, ?request_id: String?) -> SandboxSession
def create: (String project_id, region: String, ?preset: String?, ?sandbox_id: String?, ?memory_mb: Integer?, ?max_duration_seconds: Integer?, ?request_id: String?) -> SandboxSession
def get: (String session_id) -> SandboxSession
def exec: (String project_id, String command, region: String, ?preset: String?, ?sandbox_id: String?, ?memory_mb: Integer?, ?timeout_seconds: Integer, ?environment: Hash[String, String]?, ?request_id: String?) -> SandboxExecutionResult
def grant: (String session_id, String auth_user_id, expires_at: (Time | String)) -> nil
Expand All @@ -106,7 +106,7 @@ module Volcano
attr_reader project_id: String
attr_reader region: String
attr_reader state: String
attr_reader expires_at: Time
attr_reader expires_at: Time?
attr_reader files: SandboxFiles
def refresh: () -> SandboxSession
def suspend: () -> SandboxSession
Expand Down
1 change: 1 addition & 0 deletions sig_dev/sandboxes.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ module Volcano
module SandboxResponse
STATES: Array[String]
def self.timestamp: (Object? value) -> Time
def self.nullable_timestamp: (Object? value) -> Time?
def self.text: (Object? value) -> String
def self.integer: (Object? value) -> Integer
def self.flag: (Object? value) -> bool
Expand Down
51 changes: 51 additions & 0 deletions spec/volcano/sandboxes_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,57 @@ def mutated_command_response(request, input, environment, responses)
expect(handle.expires_at).to eq(Time.utc(2026, 10, 6, 10))
end

it 'sends the session lifetime and no idle timeout' do
reply(state, status: 201)
facade.create(project, region: 'aws-us-east-1', preset: 'python3.12', max_duration_seconds: 300)
body = JSON.parse(requests.last.options[:body])
expect(body).to eq('preset' => 'python3.12', 'region' => 'aws-us-east-1', 'max_duration_seconds' => 300)
end

it 'rejects the idle timeout the API removed instead of sending it' do
reply(state, status: 201)
expect { facade.create(project, region: 'aws-us-east-1', preset: 'python3.12', idle_timeout_seconds: 60) }
.to raise_error(ArgumentError, /unknown keyword: :idle_timeout_seconds/)
expect(requests).to be_empty
end

it 'creates and reads a session with no expiry when it has no lifetime limit' do
# A local-mode session has no lifetime limit, so the API returns expires_at: null.
unlimited = state.merge(expires_at: nil)
reply(unlimited, status: 201)
expect(facade.create(project, region: 'aws-us-east-1', preset: 'python3.12').expires_at).to be_nil
reply(unlimited)
expect(facade.get(session_id).expires_at).to be_nil
end

it 'refreshes between a time and no expiry in both directions' do
reply(state)
handle = facade.get(session_id)
expect(handle.expires_at).to eq(Time.utc(2026, 10, 6, 10))
reply(state.merge(expires_at: nil))
expect(handle.refresh).to be(handle)
expect(handle.expires_at).to be_nil
reply(state)
handle.refresh
expect(handle.expires_at).to eq(Time.utc(2026, 10, 6, 10))
end

it 'still rejects a session expiry that is neither a time nor null' do
reply(state)
handle = facade.get(session_id)
reply(state('terminated').merge(expires_at: 3600))
expect { handle.refresh }.to raise_error(TypeError, 'Invalid Sandbox text field')
expect(handle.state).to eq('running')
expect(handle.expires_at).to eq(Time.utc(2026, 10, 6, 10))
end

it 'keeps the expiry of an access credential required' do
reply(state)
handle = facade.get(session_id)
reply({ url: 'https://access.test', token: 'secret', expires_at: nil })
expect { handle.access(8080) }.to raise_error(TypeError, 'Invalid Sandbox text field')
end

it 'returns nonzero command exits as one-shot data' do
reply(command.merge(session_id: session_id, region: 'aws-us-east-1', duration_ms: 42))
result = facade.exec(project, 'exit 7', region: 'aws-us-east-1', preset: 'python3.12')
Expand Down