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
5 changes: 5 additions & 0 deletions lib/octaspace.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
require "octaspace/errors"
require "octaspace/payload_helpers"
require "octaspace/response"
require "octaspace/json_null"
require "octaspace/model"
require "octaspace/configuration"
require "octaspace/middleware/url_rotator"
require "octaspace/transport/base"
Expand All @@ -12,13 +14,16 @@
require "octaspace/types/balance"
require "octaspace/types/node"
require "octaspace/types/session"
require "octaspace/types/machines"
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"
require "octaspace/client"

Expand Down
4 changes: 3 additions & 1 deletion 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
:network, :services, :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 @@ -51,6 +51,8 @@ def initialize(api_key: nil, transport: nil, **opts)
@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)
end

# Shut down persistent connections (only relevant in keep_alive mode)
Expand Down
11 changes: 0 additions & 11 deletions lib/octaspace/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -82,17 +82,6 @@ class ServiceUnavailableError < ServerError; end

class GatewayTimeoutError < ServerError; end # 504

# Legacy batch-style rejection of services.mr.create (removed with that resource)
class ProvisionRejectedError < Error
attr_reader :rejections

def initialize(message = nil, response: nil, rejections: [])
@rejections = Array(rejections)
reason = @rejections.filter_map { |item| item["reason"] if item.is_a?(Hash) }.first
super(message || (reason ? "Provision request rejected: #{reason}" : "Provision request rejected"), response: response)
end
end

# HTTP status code → exception class mapping
STATUS_ERRORS = {
400 => ValidationError,
Expand Down
61 changes: 61 additions & 0 deletions lib/octaspace/json_null.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# frozen_string_literal: true

module OctaSpace
# Repairs for values the next API sometimes sends with the wrong JSON type.
#
# A missing value can arrive as the string "null" instead of JSON null: in
# GPU statistics (price percentiles, TFLOPS) and in what nodes report about
# their hardware (+mem_clock+, +power_limit+, +fan_speed+, …). Integer GPU
# fields can also arrive as strings ("", "390"). Without these repairs one
# misreporting node would break a whole listing.
#
# @api private
module JsonNull
module_function

# Replaces every "null" string value (not object keys) with nil, in place.
# Must not be applied where "null" can be real data, such as a session's
# environment variables.
# @return [Object] the same value
def null_strings!(value)
case value
when Array
value.map! { |item| (item == "null") ? nil : null_strings!(item) }
when Hash
value.each { |key, item| value[key] = (item == "null") ? nil : null_strings!(item) }
end
value
end

# @return [Integer, Float, nil] a numeric value as is; a numeric string as
# a number; anything else (blank, "null", text) as nil
def number(value)
case value
when Numeric, nil then value
when String
text = value.strip
if text.match?(/\A-?\d+\z/)
text.to_i
elsif text.match?(/\A-?\d+\.\d+\z/)
text.to_f
end
end
end

# Normalizes integer fields of each GPU object of a machine listing, in place.
# @param machines [Array<Hash>]
# @param fields [Array<String>]
def gpu_numbers!(machines, fields)
Array(machines).each do |machine|
next unless machine.is_a?(Hash)

Array(machine["gpus"]).each do |gpu|
next unless gpu.is_a?(Hash)

fields.each { |field| gpu[field] = number(gpu[field]) if gpu[field].is_a?(String) }
end
end
machines
end
end
end
102 changes: 102 additions & 0 deletions lib/octaspace/model.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# frozen_string_literal: true

module OctaSpace
# Read-only view over a JSON object returned by the API.
#
# Declared attributes get reader methods; nested objects and arrays of
# objects are wrapped in their own model classes. Fields the SDK does not
# know yet are kept and reachable with {#[]} and {#to_h}, so a new field in
# the API (it is in active development) never breaks parsing.
#
# @example
# machine = client.compute.list.first
# machine.node_number # => "M95S-WZG8-7"
# machine.gpus.first.model # => "NVIDIA GeForce RTX 3090"
# machine[:new_field] # => value of a field the SDK has no reader for
# machine.to_h # => the JSON object as parsed
class Model
class << self
# Declares attributes with reader methods.
# @param names [Array<Symbol>]
# @param model [Class, nil] wrap a nested object (or each element of an array) in this model
# @param many [Boolean] the value is an array of objects; nil reads as []
def attribute(*names, model: nil, many: false)
names.each do |name|
key = name.to_s
attribute_names << name
if model
define_method(name) { nested(key, model, many) }
else
define_method(name) { @data[key] }
end
end
end

# @return [Array<Symbol>] declared attribute names
def attribute_names
@attribute_names ||= []
end

def inherited(subclass)
super
subclass.instance_variable_set(:@attribute_names, attribute_names.dup)
end

# Wraps a JSON object; anything else (nil, a scalar) is returned as is.
def wrap(value)
value.is_a?(Hash) ? new(value) : value
end

# Wraps each JSON object of an array; nil becomes an empty array.
def wrap_all(values)
Array(values).map { |value| wrap(value) }
end
end

# @param data [Hash] JSON object with string keys
def initialize(data = {})
@data = data.to_h.transform_keys(&:to_s)
@nested = {}
end

# Value of any field, including ones without a reader
# @param key [String, Symbol]
def [](key) = @data[key.to_s]

# @param key [String, Symbol]
# @return [Boolean] whether the API sent this field (it may be null)
def key?(key) = @data.key?(key.to_s)

# @return [Hash] the JSON object as parsed (string keys, nested values unwrapped)
def to_h = @data

# Pattern matching support: +in {node_number:, base_price:}+
def deconstruct_keys(keys)
(keys || @data.keys.map(&:to_sym)).to_h { |key| [key, respond_to?(key) ? public_send(key) : @data[key.to_s]] }
end

def ==(other)
other.instance_of?(self.class) && other.to_h == to_h
end
alias_method :eql?, :==

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)
end
"#<#{self.class.name} #{shown.join(" ")}>"
end
alias_method :to_s, :inspect

private

def nested(key, model, many)
return @nested[key] if @nested.key?(key)

value = @data[key]
@nested[key] = (many || value.is_a?(Array)) ? model.wrap_all(value) : model.wrap(value)
end
end
end
20 changes: 0 additions & 20 deletions lib/octaspace/payload_helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,26 +19,6 @@ def parse_port_list(value)
end
end

def normalize_marketplace_bandwidth(value)
numeric =
case value
when nil
return nil
when Numeric
value.to_f
when String
Float(value)
else
return value
end

return numeric unless numeric > 100_000

numeric / 125_000.0
rescue ArgumentError, TypeError
value
end

def parse_stringified_port_list(value)
stripped = value.strip
return [] if stripped.empty?
Expand Down
43 changes: 40 additions & 3 deletions lib/octaspace/resources/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ module Resources
# Base class for all API resource groups
#
# Delegates HTTP methods to the transport layer and provides
# a clean DSL for subclasses.
# helpers shared by the resources.
class Base
# Integer fields of a listing's GPU objects that nodes may send as strings
GPU_INTEGER_FIELDS = %w[mem_total_mb pcie_link_gen pcie_link_width core_clock mem_clock power_limit].freeze

# @param transport [OctaSpace::Transport::Base]
def initialize(transport)
@transport = transport
Expand All @@ -20,16 +23,50 @@ def initialize(transport)

def get(path, **opts) = transport.get(path, **opts)
def post(path, **opts) = transport.post(path, **opts)
def put(path, **opts) = transport.put(path, **opts)
def patch(path, **opts) = transport.patch(path, **opts)
def delete(path, **opts) = transport.delete(path, **opts)

# Encode a single path segment to prevent path traversal
# @param segment [String, Integer]
# @return [String]
def encode(segment)
URI.encode_www_form_component(segment.to_s)
end

# Keeps only the given keys whose value is not nil, so a request never
# carries fields the caller did not set: the API refuses unknown fields,
# and treats an absent ssh/fm differently from false.
def compact_params(params, allowed)
params = params.to_h.transform_keys(&:to_sym)
unknown = params.keys - allowed
raise ArgumentError, "unknown parameter(s): #{unknown.join(", ")}" if unknown.any?

params.compact
end

# Gives every batch item an id: its own, or its position in the array.
# Raises ArgumentError when two items end up with the same id (their
# results could not be told apart, and each started session costs money).
def batch_items(items, allowed)
raise ArgumentError, "a batch needs at least one item" if Array(items).empty?

seen = {}
Array(items).each_with_index.map do |item, index|
raise ArgumentError, "batch item #{index} must be a Hash" unless item.is_a?(Hash)

params = item.to_h.transform_keys(&:to_sym)
id = params.key?(:id) ? params.delete(:id) : index
raise ArgumentError, "duplicate batch id #{id.inspect}: give every item a unique id, or none to all of them" if seen.key?(id)

seen[id] = true
{id: id, **compact_params(params, allowed)}
end
end

# Machine listings: "null" strings become nil and string GPU integers
# become numbers, so one misreporting node does not break the listing.
def normalize_machines(data)
JsonNull.gpu_numbers!(JsonNull.null_strings!(Array(data)), GPU_INTEGER_FIELDS)
end
end
end
end
Loading
Loading