Skip to content
Merged
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
3 changes: 1 addition & 2 deletions lib/octaspace.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,14 @@
require "octaspace/types/account"
require "octaspace/types/balance"
require "octaspace/types/node"
require "octaspace/types/session"
require "octaspace/types/machines"
require "octaspace/types/session"
require "octaspace/resources/base"
require "octaspace/resources/accounts"
require "octaspace/resources/nodes"
require "octaspace/resources/sessions"
require "octaspace/resources/apps"
require "octaspace/resources/network"
require "octaspace/resources/services"
require "octaspace/resources/compute"
require "octaspace/resources/render"
require "octaspace/resources/idle_jobs"
Expand Down
3 changes: 1 addition & 2 deletions lib/octaspace/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ module OctaSpace
# )
class Client
attr_reader :accounts, :nodes, :sessions, :apps,
:network, :services, :idle_jobs, :compute, :render
:network, :idle_jobs, :compute, :render

# @param api_key [String, nil] API key for authentication (optional for public endpoints)
# @param opts [Hash] Per-instance configuration overrides.
Expand All @@ -49,7 +49,6 @@ def initialize(api_key: nil, transport: nil, **opts)
@sessions = Resources::Sessions.new(@transport)
@apps = Resources::Apps.new(@transport)
@network = Resources::Network.new(@transport)
@services = Resources::Services.new(@transport)
@idle_jobs = Resources::IdleJobs.new(@transport)
@compute = Resources::Compute.new(@transport)
@render = Resources::Render.new(@transport)
Expand Down
8 changes: 8 additions & 0 deletions lib/octaspace/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ class NetworkError < Error; end
class ConnectionError < NetworkError; end
class TimeoutError < NetworkError; end

# Raised by sessions.wait_ready / wait_stopped when the timeout runs out
class WaitTimeoutError < TimeoutError; end

# An HTTP 4xx/5xx response from the API, or a 2xx response the SDK could not
# read. The next API answers errors as +{"message": "..."}+; the message is
# used as the exception message.
Expand Down Expand Up @@ -65,6 +68,11 @@ class PermissionError < ApiError; end
# stopped: the API only reports running sessions.
class NotFoundError < ApiError; end

# Raised by sessions.wait_ready when the session disappears (404) while it
# is waited for: it stopped, for example because its SSH setup failed. A
# stopped session's logs are no longer available.
class SessionStoppedError < NotFoundError; end

# 409: the operation conflicts with the current state, e.g. restarting a
# session that is still starting or restarting.
class ConflictError < ApiError; end
Expand Down
16 changes: 15 additions & 1 deletion lib/octaspace/model.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ def attribute(*names, model: nil, many: false)
end
end

# Hides the values of these attributes in #inspect (URLs that grant access).
def redact(*names)
redacted_names.concat(names)
end

# @return [Array<Symbol>]
def redacted_names
@redacted_names ||= []
end

# @return [Array<Symbol>] declared attribute names
def attribute_names
@attribute_names ||= []
Expand All @@ -40,6 +50,7 @@ def attribute_names
def inherited(subclass)
super
subclass.instance_variable_set(:@attribute_names, attribute_names.dup)
subclass.instance_variable_set(:@redacted_names, redacted_names.dup)
end

# Wraps a JSON object; anything else (nil, a scalar) is returned as is.
Expand Down Expand Up @@ -84,7 +95,10 @@ def hash = [self.class, @data].hash

def inspect
shown = self.class.attribute_names.filter_map do |name|
"#{name}=#{@data[name.to_s].inspect}" if @data.key?(name.to_s) && !@data[name.to_s].is_a?(Enumerable)
value = @data[name.to_s]
next if !@data.key?(name.to_s) || value.is_a?(Enumerable)

"#{name}=#{(value && self.class.redacted_names.include?(name)) ? "[REDACTED]" : value.inspect}"
end
"#<#{self.class.name} #{shown.join(" ")}>"
end
Expand Down
17 changes: 0 additions & 17 deletions lib/octaspace/resources/services.rb

This file was deleted.

54 changes: 0 additions & 54 deletions lib/octaspace/resources/services/session_proxy.rb

This file was deleted.

134 changes: 124 additions & 10 deletions lib/octaspace/resources/sessions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,136 @@

module OctaSpace
module Resources
# Session listing endpoint
# The account's running sessions.
#
# For operations on a specific session (info/logs/stop),
# use the proxy pattern: client.services.session("uuid")
# The API reports running sessions only: once a session stops, {#get},
# {#logs} and {#stop} raise {OctaSpace::NotFoundError}, and there is no
# session history.
#
# @example
# client.sessions.list
# client.sessions.list(recent: true)
# uuid = client.compute.start(node_number: "M95S-WZG8-7", image: "ubuntu:24.04")
# session = client.sessions.wait_ready(uuid) { |s| puts "#{s.progress} #{s.pull_percent}" }
# client.sessions.logs(uuid, tail: 50).container
# client.sessions.stop(uuid)
# client.sessions.wait_stopped(uuid)
class Sessions < Base
# List all sessions
# Default seconds between polls of {#wait_ready} and {#wait_stopped}
# (the authenticated limit is 300 requests a minute per account)
DEFAULT_WAIT_INTERVAL = 3

# @param sleeper [#call] waits the given number of seconds between polls
def initialize(transport, sleeper: ->(seconds) { sleep(seconds) })
super(transport)
@sleeper = sleeper
end

# Running sessions of the account, oldest first.
# GET /sessions
# @param params [Hash] optional filter params
# @return [OctaSpace::Response]
def list(**params)
get("/sessions", params:)
# @return [Array<OctaSpace::Types::Session>] ComputeSession / RenderSession,
# or a plain Session for a service this SDK does not know
def list
Array(get_raw("/sessions").data).map { |item| Types::Session.build(item) }
end

# A running session.
# GET /services/{uuid}/info
#
# Raises {OctaSpace::NotFoundError} when it has stopped or never existed,
# and {OctaSpace::ValidationError} for a malformed uuid.
# @param uuid [String]
# @return [OctaSpace::Types::Session]
def get(uuid)
Types::Session.build(get_raw(session_path(uuid, "info")).data)
end

# System log and latest container output of a running compute session.
# GET /services/{uuid}/logs
#
# Render sessions have no logs; a stopped session's logs are gone (404).
# @param uuid [String]
# @param tail [Integer, nil] last lines of container output, 1..1000 (API default 200)
# @return [OctaSpace::Types::SessionLogs]
def logs(uuid, tail: nil)
Types::SessionLogs.new(get_raw(session_path(uuid, "logs"), params: {tail: tail}).data)
end

# Stops a session.
# POST /services/{uuid}/stop (→ 202)
#
# Stopping is asynchronous: the session is gone once {#get} raises
# NotFoundError (see {#wait_stopped}). Repeating the call while the
# session is stopping is harmless, so it is retried on transient errors.
# A session stopped before its +min_rent_minutes+ is charged base and
# storage for that long.
# @param uuid [String]
# @return [String] the session uuid
def stop(uuid)
response = post(session_path(uuid, "stop"), idempotent: true)
response.data.is_a?(Hash) ? response.data.fetch("uuid", uuid) : uuid
end

# Polls the session until its status is "ready" and returns it.
#
# After +compute.restart+ the session may still report "ready" for a
# moment: wait until it leaves "ready" first if you need to observe the
# restart finishing.
#
# @param uuid [String]
# @param timeout [Numeric, nil] seconds; nil waits indefinitely
# @param interval [Numeric] seconds between polls
# @yieldparam session [OctaSpace::Types::Session] every polled state (progress reporting)
# @return [OctaSpace::Types::Session]
# @raise [OctaSpace::SessionStoppedError] the session stopped meanwhile
# @raise [OctaSpace::WaitTimeoutError] the timeout ran out
def wait_ready(uuid, timeout: nil, interval: DEFAULT_WAIT_INTERVAL)
poll(timeout, interval) do
session = begin
get(uuid)
rescue NotFoundError => e
raise SessionStoppedError.new("Session #{uuid} stopped before it became ready: #{e.message}", response: e.response)
end
yield session if block_given?
session if session.ready?
end
end

# Polls until the API no longer reports the session (404), i.e. it has
# stopped. Returns at once for a session that is already gone.
#
# @param uuid [String]
# @param timeout [Numeric, nil] seconds; nil waits indefinitely
# @param interval [Numeric] seconds between polls
# @return [true]
# @raise [OctaSpace::WaitTimeoutError] the timeout ran out
def wait_stopped(uuid, timeout: nil, interval: DEFAULT_WAIT_INTERVAL)
poll(timeout, interval) do
get(uuid)
nil
rescue NotFoundError
true
end
end

private

def get_raw(path, **opts) = transport.get(path, **opts)

def session_path(uuid, action) = "/services/#{encode(uuid)}/#{action}"

def poll(timeout, interval)
deadline = timeout && (monotonic + timeout)
loop do
result = yield
return result if result
if deadline && monotonic + interval > deadline
raise WaitTimeoutError, "Gave up waiting after #{timeout} seconds"
end

@sleeper.call(interval)
end
end

def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
end
end
end
Loading
Loading