diff --git a/lib/octaspace.rb b/lib/octaspace.rb index ea61c7d..f95b98c 100644 --- a/lib/octaspace.rb +++ b/lib/octaspace.rb @@ -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" @@ -12,6 +14,7 @@ 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" @@ -19,6 +22,8 @@ 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" diff --git a/lib/octaspace/client.rb b/lib/octaspace/client.rb index 63809ee..3388d30 100644 --- a/lib/octaspace/client.rb +++ b/lib/octaspace/client.rb @@ -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. @@ -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) diff --git a/lib/octaspace/errors.rb b/lib/octaspace/errors.rb index 6f4bef7..d4405b2 100644 --- a/lib/octaspace/errors.rb +++ b/lib/octaspace/errors.rb @@ -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, diff --git a/lib/octaspace/json_null.rb b/lib/octaspace/json_null.rb new file mode 100644 index 0000000..04e59b5 --- /dev/null +++ b/lib/octaspace/json_null.rb @@ -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] + # @param fields [Array] + 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 diff --git a/lib/octaspace/model.rb b/lib/octaspace/model.rb new file mode 100644 index 0000000..641cc4b --- /dev/null +++ b/lib/octaspace/model.rb @@ -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] + # @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] 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 diff --git a/lib/octaspace/payload_helpers.rb b/lib/octaspace/payload_helpers.rb index 7d120bf..b944b88 100644 --- a/lib/octaspace/payload_helpers.rb +++ b/lib/octaspace/payload_helpers.rb @@ -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? diff --git a/lib/octaspace/resources/base.rb b/lib/octaspace/resources/base.rb index ba5b8e8..abe0ce1 100644 --- a/lib/octaspace/resources/base.rb +++ b/lib/octaspace/resources/base.rb @@ -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 @@ -20,9 +23,7 @@ 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] @@ -30,6 +31,42 @@ def delete(path, **opts) = transport.delete(path, **opts) 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 diff --git a/lib/octaspace/resources/compute.rb b/lib/octaspace/resources/compute.rb new file mode 100644 index 0000000..0b1a168 --- /dev/null +++ b/lib/octaspace/resources/compute.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +module OctaSpace + module Resources + # Compute machines: listing, start (single and batch) and restart. + # + # @example + # machine = client.compute.list.min_by(&:base_price) + # uuid = client.compute.start(node_number: machine.node_number, image: "ubuntu:24.04", + # disk_size: 10, ssh: true) + # client.sessions.wait_ready(uuid) + class Compute < Base + # Parameters that {#restart} replaces + RESTART_PARAMS = %i[disk_size envs ports http_ports entrypoint args ssh fm startup_script].freeze + + # Parameters of {#start} + START_PARAMS = (%i[node_number image app] + RESTART_PARAMS).freeze + + # Machines available for rent. + # GET /services/compute + # + # Works without an API key, but the anonymous listing is cached for up + # to an hour; with a key it is at most 10 seconds old. + # @return [Array] + def list + Types::ComputeMachine.wrap_all(normalize_machines(get("/services/compute", auth: :optional).data)) + end + + # Starts one session. + # POST /services/compute (object → 201) + # + # Validation and business errors ("Insufficient balance", "Node not + # available", …) raise {OctaSpace::ValidationError}. Never retried + # automatically: a start costs money. + # + # @param node_number [String] node number from the listing, e.g. "M95S-WZG8-7" + # (case, dashes and spaces are ignored by the API) + # @param image [String] container image, e.g. "ubuntu:24.04" + # @param app [String, nil] application uuid + # @param disk_size [Integer, nil] disk, GB (API default 1) + # @param envs [Hash{String=>String}, nil] up to 64 variables, values up to 4 KiB + # @param ports [Array, nil] TCP/UDP ports to expose (0 and 22 are ignored) + # @param http_ports [Array, nil] HTTP ports to expose (7681, 7682 are reserved) + # @param entrypoint [String, nil] replaces the image's ENTRYPOINT (one line, up to 4 KiB) + # @param args [String, nil] replaces the image's CMD (one line, up to 4 KiB) + # @param ssh [Boolean, nil] web terminal and SSH access; nil = the app's setting + # @param fm [Boolean, nil] web file manager (no login: anyone with the URL can use it); + # nil = the app's setting + # @param startup_script [String, nil] run as root in the background once the container is up + # @return [String] the session uuid + def start(**params) + missing = %i[node_number image].reject { |key| params.key?(key) } + raise ArgumentError, "missing keyword(s): #{missing.join(", ")}" if missing.any? + + post("/services/compute", body: compact_params(params, START_PARAMS)).data.fetch("uuid") + end + + # Starts several sessions at once. + # POST /services/compute (array → 200) + # + # Each item is validated on its own and reported in request order; a + # failed item does not fail the batch. Items without an +:id+ get their + # position in the array; ids may be integers or strings. + # + # @param items [Array] {#start} parameters, each with an optional +:id+ + # @return [Array] + def start_batch(items) + Types::BatchResult.wrap_all(post("/services/compute", body: batch_items(items, START_PARAMS)).data) + end + + # Recreates the session's container on the same node, keeping the rent, + # uuid and prices. + # POST /services/compute/{uuid}/restart (→ 202) + # + # Follow the progress with +client.sessions.get(uuid)+; if the new + # parameters cannot be applied, the session stops. + # + # - Without parameters: restarts with the original parameters. + # - With parameters: *replaces all of them*. Every omitted parameter gets + # a fixed default, not its current value: +disk_size+ 1 GB, + # +entrypoint+/+args+ from the image, no +startup_script+, no +envs+, + # no +ports+/+http_ports+, +ssh+/+fm+ from the app (off without one). + # The API does not report a session's launch parameters, so pass the + # full set you want, +disk_size+ included. + # + # Raises {OctaSpace::ConflictError} (409) while the session is still + # starting or restarting, and {OctaSpace::RateLimitError} (429) when it is + # restarted more often than once per 5 minutes or 6 times per hour + # (+retry_after+ says how long to wait). Never retried automatically. + # + # @param uuid [String] + # @param params [Hash] see {#start}, without node_number, image and app + # @return [String] the session uuid + def restart(uuid, **params) + body = compact_params(params, RESTART_PARAMS) + response = post("/services/compute/#{encode(uuid)}/restart", body: body.empty? ? nil : body) + response.data.is_a?(Hash) ? response.data.fetch("uuid", uuid) : uuid + end + end + end +end diff --git a/lib/octaspace/resources/render.rb b/lib/octaspace/resources/render.rb new file mode 100644 index 0000000..7b8f04a --- /dev/null +++ b/lib/octaspace/resources/render.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module OctaSpace + module Resources + # Render nodes: listing and start (single and batch). + # + # @example + # node = client.render.list.max_by(&:blender_score) + # uuid = client.render.start(node_number: node.node_number) + class Render < Base + START_PARAMS = %i[node_number].freeze + + # Nodes available for render. + # GET /services/render + # + # Works without an API key; with a key the listing is fresher. + # @return [Array] + def list + Types::RenderMachine.wrap_all(normalize_machines(get("/services/render", auth: :optional).data)) + end + + # Rents a node as a render node, billed by the hour until stopped. + # POST /services/render (object → 201) + # + # While render is temporarily unavailable this raises + # {OctaSpace::ServiceUnavailableError} (503). Never retried automatically. + # @param node_number [String] + # @return [String] the session uuid + def start(node_number:) + post("/services/render", body: {node_number: node_number}).data.fetch("uuid") + end + + # Rents several render nodes at once. + # POST /services/render (array → 200) + # + # Unlike {#start}, a temporarily unavailable service is reported per item + # (+status: 1+, +reason: "Service is temporarily unavailable"+), not as + # an HTTP 503. + # @param items [Array] each +{node_number:}+ with an optional +:id+ + # @return [Array] + def start_batch(items) + Types::BatchResult.wrap_all(post("/services/render", body: batch_items(items, START_PARAMS)).data) + end + end + end +end diff --git a/lib/octaspace/resources/services.rb b/lib/octaspace/resources/services.rb index a8ec9fb..5ba8cf9 100644 --- a/lib/octaspace/resources/services.rb +++ b/lib/octaspace/resources/services.rb @@ -2,27 +2,8 @@ module OctaSpace module Resources - # Services namespace — aggregates MR, VPN, Render subresources - # and provides the session proxy pattern - # - # @example - # client.services.mr.list - # client.services.vpn.create(node_id: 123) - # client.services.render.create(node_id: 456, disk_size: 100) - # - # # Session proxy pattern - # client.services.session("uuid-123").info - # client.services.session("uuid-123").stop(score: 5) + # Legacy session proxy: client.services.session("uuid").info / logs / stop class Services < Base - attr_reader :mr, :vpn, :render - - def initialize(transport) - super - @mr = Services::MachineRental.new(transport) - @vpn = Services::Vpn.new(transport) - @render = Services::Render.new(transport) - end - # Return a proxy object for operations on a specific session # @param uuid [String] session UUID # @return [OctaSpace::Resources::Services::SessionProxy] @@ -34,6 +15,3 @@ def session(uuid) end require_relative "services/session_proxy" -require_relative "services/machine_rental" -require_relative "services/vpn" -require_relative "services/render" diff --git a/lib/octaspace/resources/services/machine_rental.rb b/lib/octaspace/resources/services/machine_rental.rb deleted file mode 100644 index 3b9e54f..0000000 --- a/lib/octaspace/resources/services/machine_rental.rb +++ /dev/null @@ -1,78 +0,0 @@ -# frozen_string_literal: true - -module OctaSpace - module Resources - class Services - # Machine Rental (MR) service endpoints - # - # @example - # client.services.mr.list - # client.services.mr.create( - # node_id: 123, - # disk_size: 10, - # image: "ubuntu:24.04", - # app: "249b4cb3-3db1-4c06-98a4-772ba88cd81c" - # ) - class MachineRental < Base - # List available marketplace machines for rent - # GET /services/mr - # @param params [Hash] optional filter params - # @return [OctaSpace::Response] - def list(**params) - get("/services/mr", params:, auth: :optional) - end - - # Create (start) a machine rental - # POST /services/mr - # @param attrs [Hash] rental parameters - # @return [OctaSpace::Response] - def create(**attrs) - item = { - id: 0, - node_id: attrs.fetch(:node_id), - disk_size: attrs.fetch(:disk_size), - image: attrs.fetch(:image), - app: attrs[:app].to_s, - envs: attrs[:envs] || {}, - ports: attrs[:ports] || [], - http_ports: attrs[:http_ports] || [], - start_command: attrs[:start_command].to_s, - entrypoint: attrs[:entrypoint].to_s - } - - item[:organization_id] = attrs[:organization_id] if attrs.key?(:organization_id) - item[:project_id] = attrs[:project_id] if attrs.key?(:project_id) - - response = post("/services/mr", body: [item]) - raise_if_rejected!(response) - response - end - - private - - def raise_if_rejected!(response) - rejections = extract_rejections(response.data) - return if rejections.empty? - - raise OctaSpace::ProvisionRejectedError.new(response: response, rejections: rejections) - end - - def extract_rejections(data) - return [] unless data.is_a?(Array) - - data.filter_map do |item| - next unless item.is_a?(Hash) - - reason = item["reason"] || item[:reason] - status = item["status"] || item[:status] - uuid = item["uuid"] || item[:uuid] - next if uuid - next unless reason || status.to_i.positive? - - item - end - end - end - end - end -end diff --git a/lib/octaspace/resources/services/render.rb b/lib/octaspace/resources/services/render.rb deleted file mode 100644 index e8a39e6..0000000 --- a/lib/octaspace/resources/services/render.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -module OctaSpace - module Resources - class Services - # Render service endpoints - # - # @example - # client.services.render.list - # client.services.render.create(node_id: 123, disk_size: 100) - class Render < Base - # List render jobs - # GET /services/render - # @param params [Hash] optional filter params - # @return [OctaSpace::Response] - def list(**params) - get("/services/render", params:, auth: :optional) - end - - # Create (start) a render job - # POST /services/render - # @param attrs [Hash] render job parameters - # @return [OctaSpace::Response] - def create(**attrs) - post("/services/render", body: attrs) - end - end - end - end -end diff --git a/lib/octaspace/resources/services/vpn.rb b/lib/octaspace/resources/services/vpn.rb deleted file mode 100644 index 00d114c..0000000 --- a/lib/octaspace/resources/services/vpn.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -module OctaSpace - module Resources - class Services - # VPN service endpoints - # - # @example - # client.services.vpn.list - # client.services.vpn.create(node_id: 123) - class Vpn < Base - # List available VPN relay nodes - # GET /services/vpn - # @param params [Hash] optional filter params - # @return [OctaSpace::Response] - def list(**params) - get("/services/vpn", params:) - end - - # Create (start) a VPN session - # POST /services/vpn - # @param attrs [Hash] VPN parameters - # @return [OctaSpace::Response] - def create(**attrs) - post("/services/vpn", body: attrs) - end - end - end - end -end diff --git a/lib/octaspace/types/machines.rb b/lib/octaspace/types/machines.rb new file mode 100644 index 0000000..7391f2f --- /dev/null +++ b/lib/octaspace/types/machines.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module OctaSpace + module Types + # A GPU of a machine listing (+GpuNvidia+ in the API spec) + class Gpu < Model + # @!attribute [r] model + # @return [String] GPU model name + # @!attribute [r] mem_total_mb + # @return [Integer] total memory, MB + # @!attribute [r] core_clock + # @return [Integer, nil] core clock ceiling, MHz + # @!attribute [r] mem_clock + # @return [Integer, nil] memory clock ceiling, MHz + # @!attribute [r] power_limit + # @return [Integer, nil] power limit, W; nil if the card reports none + # @!attribute [r] tuned + # @return [Boolean] the node owner changed the card's power limit or clocks + attribute :model, :mem_total_mb, :pcie_link_gen, :pcie_link_width, + :core_clock, :mem_clock, :power_limit, :tuned + + def tuned? = tuned == true + end + + # Node reliability (an open object in the API). {#uptime} is the percent of + # time online over the last 30 days; other keys via {#[]}. + class Reliability < Model + attribute :uptime, :stability, :s_normal, :s_failed, :s_avg_d, :s_max_d + end + + # Fields shared by compute and render listings + class Machine < Model + # @!attribute [r] node_number + # @return [String] node number, e.g. "M95S-WZG8-7" + # @!attribute [r] base_price + # @return [Integer] price per hour, micro-USD + # @!attribute [r] min_rent_minutes + # @return [Integer] minimum rent, minutes (0 = none); a session stopped + # sooner is charged for that long + # @!attribute [r] memory + # @return [Integer] free memory, bytes + # @!attribute [r] disk + # @return [Integer] free disk, bytes + # @!attribute [r] net_down_mbps + # @return [Integer] download speed, Mbit/s; 0 if not measured + # @!attribute [r] net_up_mbps + # @return [Integer] upload speed, Mbit/s; 0 if not measured + attribute :node_number, :base_price, :min_rent_minutes, + :blender_score, :city, :country, :cpu_cores, :cpu_model_name, :cuda_version, + :disk, :memory, :gpu_vendor, :net_down_mbps, :net_up_mbps + attribute :gpus, model: Gpu, many: true + attribute :reliability, model: Reliability + end + + # A machine available for rent (+GET /services/compute+) + class ComputeMachine < Machine + # @!attribute [r] storage_price + # @return [Integer] price per GB of disk per hour, micro-USD + # @!attribute [r] traffic_price + # @return [Integer] price per GB of traffic, micro-USD + # @!attribute [r] disk_type + # @return [String, nil] "nvme", "ssd", "hdd" or nil if unknown + attribute :storage_price, :traffic_price, :arch, :ai_score, + :disk_type, :disk_speed_mbs, :memory_speed, :memory_type, :free_ports + end + + # A node available for render (+GET /services/render+); billed by the hour only + class RenderMachine < Machine + end + + # Outcome of one item of a batch start + class BatchResult < Model + # @!attribute [r] id + # @return [Integer, String] the client-side id of the request item + # @!attribute [r] status + # @return [Integer] 0 = started, 1 = failed + # @!attribute [r] uuid + # @return [String, nil] session uuid when started + # @!attribute [r] reason + # @return [String, nil] why the item failed + attribute :id, :status, :uuid, :reason + + def started? = status == 0 + def failed? = !started? + end + end +end diff --git a/test/fixtures/next/batch.json b/test/fixtures/next/batch.json new file mode 100644 index 0000000..8c4001e --- /dev/null +++ b/test/fixtures/next/batch.json @@ -0,0 +1,17 @@ +[ + { + "id": 1, + "status": 0, + "uuid": "eeda4f74-0ccb-420e-b718-3f0ec56e397f" + }, + { + "id": "two", + "reason": "node_number must be a node number, e.g. 7KQ4-M2XD-4", + "status": 1 + }, + { + "id": 3, + "reason": "image is required", + "status": 1 + } +] \ No newline at end of file diff --git a/test/fixtures/next/services_compute.json b/test/fixtures/next/services_compute.json new file mode 100644 index 0000000..7882463 --- /dev/null +++ b/test/fixtures/next/services_compute.json @@ -0,0 +1,167 @@ +[ + { + "memory": 63785484288, + "reliability": { + "uptime": 92.03747157535116, + "s_avg_d": 7887, + "s_failed": 0, + "s_max_d": 86930, + "s_normal": 44, + "stability": 100.0 + }, + "city": "Mukachevo", + "country": "Ukraine", + "traffic_price": 100, + "storage_price": 0, + "node_number": "7R2S-S05E-S", + "net_up_mbps": 466, + "net_down_mbps": 222, + "min_rent_minutes": 10, + "memory_type": "DDR4", + "memory_speed": 3200, + "gpus": [ + { + "core_clock": 2130, + "mem_clock": 9751, + "mem_total_mb": 24576, + "model": "NVIDIA GeForce RTX 3090", + "pcie_link_gen": 3, + "pcie_link_width": 16, + "power_limit": 390, + "tuned": false + } + ], + "gpu_vendor": "nvidia", + "free_ports": 0, + "disk_type": "nvme", + "disk_speed_mbs": 3263, + "disk": 773940592640, + "cuda_version": "13.2", + "cpu_model_name": "AMD Ryzen 5 5600X 6-Core Processor", + "cpu_cores": 12, + "blender_score": 5101, + "base_price": 100000, + "arch": "x86_64", + "ai_score": 36759 + }, + { + "memory": 128846458880, + "reliability": { + "uptime": 96.2168089037879, + "s_avg_d": 12635, + "s_failed": 0, + "s_max_d": 86960, + "s_normal": 14, + "stability": 100.0 + }, + "city": "Mukachevo", + "country": "Ukraine", + "traffic_price": 100, + "storage_price": 100, + "node_number": "M95S-WZG8-7", + "net_up_mbps": 466, + "net_down_mbps": 240, + "min_rent_minutes": 0, + "memory_type": "DDR5", + "memory_speed": 4800, + "gpus": [ + { + "core_clock": 2130, + "mem_clock": 9751, + "mem_total_mb": 24576, + "model": "NVIDIA GeForce RTX 3090", + "pcie_link_gen": 4, + "pcie_link_width": 16, + "power_limit": 390, + "tuned": false + }, + { + "core_clock": 2130, + "mem_clock": 9751, + "mem_total_mb": 24576, + "model": "NVIDIA GeForce RTX 3090", + "pcie_link_gen": 4, + "pcie_link_width": 16, + "power_limit": 390, + "tuned": false + }, + { + "core_clock": 2130, + "mem_clock": 9751, + "mem_total_mb": 24576, + "model": "NVIDIA GeForce RTX 3090", + "pcie_link_gen": 4, + "pcie_link_width": 16, + "power_limit": 390, + "tuned": false + } + ], + "gpu_vendor": "nvidia", + "free_ports": 0, + "disk_type": "nvme", + "disk_speed_mbs": 4757, + "disk": 1193761476608, + "cuda_version": "13.2", + "cpu_model_name": "AMD Ryzen Threadripper 7960X 24-Cores", + "cpu_cores": 48, + "blender_score": 15438, + "base_price": 100, + "arch": "x86_64", + "ai_score": 109451 + }, + { + "memory": 96366718976, + "reliability": { + "uptime": 83.78341416810808, + "s_avg_d": 1516, + "s_failed": 0, + "s_max_d": 2857, + "s_normal": 2, + "stability": 100.0 + }, + "city": "Mukachevo", + "country": "Ukraine", + "traffic_price": 100, + "storage_price": 100, + "node_number": "WJFM-MYWR-9", + "net_up_mbps": 466, + "net_down_mbps": 241, + "min_rent_minutes": 0, + "memory_type": "DDR5", + "memory_speed": 5600, + "gpus": [ + { + "core_clock": 2850, + "mem_clock": "null", + "mem_total_mb": 12288, + "model": "Arc B580", + "pcie_link_gen": 4, + "pcie_link_width": 8, + "power_limit": "null", + "tuned": false + }, + { + "core_clock": 2850, + "mem_clock": "null", + "mem_total_mb": 12288, + "model": "Arc B580", + "pcie_link_gen": 4, + "pcie_link_width": 4, + "power_limit": "null", + "tuned": false + } + ], + "gpu_vendor": "intel", + "free_ports": 0, + "disk_type": "nvme", + "disk_speed_mbs": 6580, + "disk": 1439272214528, + "cuda_version": null, + "cpu_model_name": "AMD Ryzen 7 7700 8-Core Processor", + "cpu_cores": 16, + "blender_score": 5062, + "base_price": 100, + "arch": "x86_64", + "ai_score": 0 + } +] \ No newline at end of file diff --git a/test/fixtures/next/services_render.json b/test/fixtures/next/services_render.json new file mode 100644 index 0000000..26ae8d4 --- /dev/null +++ b/test/fixtures/next/services_render.json @@ -0,0 +1,94 @@ +[ + { + "memory": 63785484288, + "reliability": { + "uptime": 92.03747157535116, + "s_avg_d": 7887, + "s_failed": 0, + "s_max_d": 86930, + "s_normal": 44, + "stability": 100.0 + }, + "city": "Mukachevo", + "country": "Ukraine", + "node_number": "7R2S-S05E-S", + "net_up_mbps": 466, + "net_down_mbps": 222, + "min_rent_minutes": 10, + "gpus": [ + { + "core_clock": 2130, + "mem_clock": 9751, + "mem_total_mb": 24576, + "model": "NVIDIA GeForce RTX 3090", + "pcie_link_gen": 3, + "pcie_link_width": 16, + "power_limit": 390, + "tuned": false + } + ], + "gpu_vendor": "nvidia", + "disk": 773940592640, + "cuda_version": "13.2", + "cpu_model_name": "AMD Ryzen 5 5600X 6-Core Processor", + "cpu_cores": 12, + "blender_score": 5101, + "base_price": 100000 + }, + { + "memory": 128846458880, + "reliability": { + "uptime": 96.2168089037879, + "s_avg_d": 12635, + "s_failed": 0, + "s_max_d": 86960, + "s_normal": 14, + "stability": 100.0 + }, + "city": "Mukachevo", + "country": "Ukraine", + "node_number": "M95S-WZG8-7", + "net_up_mbps": 466, + "net_down_mbps": 240, + "min_rent_minutes": 0, + "gpus": [ + { + "core_clock": 2130, + "mem_clock": 9751, + "mem_total_mb": 24576, + "model": "NVIDIA GeForce RTX 3090", + "pcie_link_gen": 4, + "pcie_link_width": 16, + "power_limit": 390, + "tuned": false + }, + { + "core_clock": 2130, + "mem_clock": 9751, + "mem_total_mb": 24576, + "model": "NVIDIA GeForce RTX 3090", + "pcie_link_gen": 4, + "pcie_link_width": 16, + "power_limit": 390, + "tuned": false + }, + { + "core_clock": 2130, + "mem_clock": 9751, + "mem_total_mb": 24576, + "model": "NVIDIA GeForce RTX 3090", + "pcie_link_gen": 4, + "pcie_link_width": 16, + "power_limit": 390, + "tuned": false + } + ], + "gpu_vendor": "nvidia", + "disk": 1193761476608, + "cuda_version": "13.2", + "cpu_model_name": "AMD Ryzen Threadripper 7960X 24-Cores", + "cpu_cores": 48, + "blender_score": 15438, + "base_price": 100 + } +] \ No newline at end of file diff --git a/test/fixtures/services/mr/index.json b/test/fixtures/services/mr/index.json deleted file mode 100644 index f129952..0000000 --- a/test/fixtures/services/mr/index.json +++ /dev/null @@ -1,21 +0,0 @@ -[ - { - "node_id": 9010, - "country": "France", - "city": "Paris", - "net_down_mbits": 872229553, - "net_up_mbits": 62488613, - "gpu": "NVIDIA GeForce RTX 5090", - "base_usd": 3400, - "traffic_usd": 1, - "reliability": { - "uptime": 100, - "rating": {"count": 0, "score": 0}, - "s_avg_d": 10117, - "s_failed": 1, - "s_max_d": 366353, - "s_normal": 381, - "stability": 99.74 - } - } -] diff --git a/test/fixtures/services/render/index.json b/test/fixtures/services/render/index.json deleted file mode 100644 index 012e2a8..0000000 --- a/test/fixtures/services/render/index.json +++ /dev/null @@ -1,13 +0,0 @@ -[ - { - "node_id": 9042, - "country": "Germany", - "city": "Frankfurt", - "gpu": "NVIDIA RTX A6000", - "net_down_mbits": 125000000, - "net_up_mbits": 62500000, - "base_usd": 1200, - "traffic_usd": 1, - "reliability": 99.5 - } -] diff --git a/test/fixtures/services/vpn/index.json b/test/fixtures/services/vpn/index.json deleted file mode 100644 index d946171..0000000 --- a/test/fixtures/services/vpn/index.json +++ /dev/null @@ -1,12 +0,0 @@ -[ - { - "node_id": 9539, - "country": "Poland", - "city": "Zakrzew", - "residential": false, - "net_down_mbits": 0, - "net_up_mbits": 0, - "traffic_price_usd": 0.005366743801677206, - "traffic_price_ether": 0.05 - } -] diff --git a/test/octaspace/json_null_test.rb b/test/octaspace/json_null_test.rb new file mode 100644 index 0000000..fbabeb5 --- /dev/null +++ b/test/octaspace/json_null_test.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "test_helper" + +class OctaSpace::JsonNullTest < Minitest::Test + def test_null_strings_replaced_recursively_but_not_keys + data = {"null" => "null", "a" => ["null", {"b" => "null", "c" => "x"}], "d" => 1} + OctaSpace::JsonNull.null_strings!(data) + + assert_equal({"null" => nil, "a" => [nil, {"b" => nil, "c" => "x"}], "d" => 1}, data) + end + + def test_number + assert_equal 390, OctaSpace::JsonNull.number(" 390 ") + assert_in_delta 1.5, OctaSpace::JsonNull.number("1.5") + assert_nil OctaSpace::JsonNull.number("") + assert_nil OctaSpace::JsonNull.number("null") + assert_equal 7, OctaSpace::JsonNull.number(7) + assert_nil OctaSpace::JsonNull.number(nil) + end +end diff --git a/test/octaspace/model_test.rb b/test/octaspace/model_test.rb new file mode 100644 index 0000000..f99b458 --- /dev/null +++ b/test/octaspace/model_test.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "test_helper" + +class OctaSpace::ModelTest < Minitest::Test + class Inner < OctaSpace::Model + attribute :name + end + + class Outer < OctaSpace::Model + attribute :id + attribute :inner, model: Inner + attribute :items, model: Inner, many: true + end + + def setup + super + @outer = Outer.new("id" => 1, "inner" => {"name" => "a"}, "items" => [{"name" => "b"}, nil], "extra" => true) + end + + def test_readers_and_nested_models + assert_equal 1, @outer.id + assert_equal "a", @outer.inner.name + assert_equal ["b", nil], @outer.items.map { |i| i&.name } + assert_same @outer.inner, @outer.inner + end + + def test_missing_and_null_values + empty = Outer.new({}) + assert_nil empty.id + assert_nil empty.inner + assert_equal [], Outer.new("items" => nil).items + refute empty.key?(:id) + end + + def test_unknown_fields_and_raw_hash + assert_equal true, @outer[:extra] + assert_equal true, @outer["extra"] + assert_equal({"name" => "a"}, @outer.to_h["inner"]) + end + + def test_equality_and_inspect + assert_equal Outer.new("id" => 1), Outer.new(id: 1) + refute_equal Outer.new("id" => 1), Inner.new("id" => 1) + assert_equal "#", Outer.new("id" => 1, "inner" => {}).inspect + end + + def test_pattern_matching + case @outer + in {id: Integer => id, extra: true} + assert_equal 1, id + end + end + + def test_attributes_are_inherited + sub = Class.new(Outer) { attribute :more } + assert_equal %i[id inner items more], sub.attribute_names + assert_equal %i[id inner items], Outer.attribute_names + end +end diff --git a/test/octaspace/payload_helpers_test.rb b/test/octaspace/payload_helpers_test.rb index 9699653..58593ac 100644 --- a/test/octaspace/payload_helpers_test.rb +++ b/test/octaspace/payload_helpers_test.rb @@ -18,16 +18,4 @@ def test_parse_port_list_returns_empty_array_for_invalid_string def test_parse_port_list_returns_empty_array_for_nil assert_equal [], OctaSpace::PayloadHelpers.parse_port_list(nil) end - - def test_normalize_marketplace_bandwidth_preserves_small_values - assert_equal 250.0, OctaSpace::PayloadHelpers.normalize_marketplace_bandwidth(250) - end - - def test_normalize_marketplace_bandwidth_converts_large_values - assert_in_delta 6977.836424, OctaSpace::PayloadHelpers.normalize_marketplace_bandwidth(872_229_553), 0.000001 - end - - def test_normalize_marketplace_bandwidth_returns_nil_for_nil - assert_nil OctaSpace::PayloadHelpers.normalize_marketplace_bandwidth(nil) - end end diff --git a/test/octaspace/resources/compute_test.rb b/test/octaspace/resources/compute_test.rb new file mode 100644 index 0000000..f45a3a3 --- /dev/null +++ b/test/octaspace/resources/compute_test.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +require "test_helper" + +class OctaSpace::ComputeResourceTest < Minitest::Test + BASE = StubHelpers::BASE_URL + UUID = "2a63b54f-f243-45ab-8416-2728e178a384" + + def setup + super + @client = test_client + end + + # --- list --- + + def test_list_returns_machines_from_live_fixture + stub_get("/services/compute", fixture_path: "next/services_compute.json") + machines = @client.compute.list + + assert_equal 3, machines.size + m = machines.find { |x| x.node_number == "M95S-WZG8-7" } + assert_instance_of OctaSpace::Types::ComputeMachine, m + assert_equal 100, m.base_price + assert_equal 100, m.storage_price + assert_equal 0, m.min_rent_minutes + assert_equal 240, m.net_down_mbps + assert_equal "nvme", m.disk_type + assert_equal "NVIDIA GeForce RTX 3090", m.gpus.first.model + assert_equal 24_576, m.gpus.first.mem_total_mb + assert_in_delta 96.2, m.reliability.uptime, 0.1 + assert_equal 14, m.reliability[:s_normal] + end + + def test_list_turns_null_strings_into_nil + stub_get("/services/compute", fixture_path: "next/services_compute.json") + arc = @client.compute.list.find { |x| x.gpus.any? { |g| g.model.to_s.include?("Arc") } } + + assert_nil arc.gpus.first.mem_clock + assert_nil arc.gpus.first.power_limit + end + + def test_list_coerces_string_gpu_integers + stub_request(:get, "#{BASE}/services/compute").to_return(status: 200, + body: '[{"node_number":"A","gpus":[{"power_limit":"390","mem_clock":"","core_clock":"n/a"}]}]') + gpu = @client.compute.list.first.gpus.first + + assert_equal 390, gpu.power_limit + assert_nil gpu.mem_clock + assert_nil gpu.core_clock + end + + def test_list_works_without_api_key + stub_request(:get, "#{BASE}/services/compute").to_return(status: 200, body: "[]") + assert_equal [], OctaSpace::Client.new.compute.list + assert_requested(:get, "#{BASE}/services/compute") { |req| !req.headers.key?("Authorization") } + end + + def test_list_sends_key_when_present + stub_request(:get, "#{BASE}/services/compute").to_return(status: 200, body: "[]") + @client.compute.list + assert_requested :get, "#{BASE}/services/compute", headers: {"Authorization" => "Bearer test_key_123"} + end + + def test_unknown_fields_are_kept + stub_request(:get, "#{BASE}/services/compute").to_return(status: 200, body: '[{"node_number":"A","future":42}]') + m = @client.compute.list.first + + assert_equal 42, m[:future] + assert_equal({"node_number" => "A", "future" => 42}, m.to_h) + end + + # --- start --- + + def test_start_sends_object_and_returns_uuid + stub_request(:post, "#{BASE}/services/compute") + .with(body: {node_number: "M95S-WZG8-7", image: "alpine:3.20", disk_size: 1, envs: {"A" => "1"}, + http_ports: [8080], ssh: false}.to_json) + .to_return(status: 201, body: %({"uuid":"#{UUID}"})) + + uuid = @client.compute.start(node_number: "M95S-WZG8-7", image: "alpine:3.20", disk_size: 1, + envs: {"A" => "1"}, http_ports: [8080], ssh: false, fm: nil) + assert_equal UUID, uuid + end + + def test_start_requires_node_number_and_image + error = assert_raises(ArgumentError) { @client.compute.start(image: "x") } + assert_includes error.message, "node_number" + end + + def test_start_rejects_unknown_parameters + error = assert_raises(ArgumentError) { @client.compute.start(node_number: "A", image: "x", start_command: "sh") } + assert_includes error.message, "start_command" + end + + def test_start_maps_400_to_validation_error + stub_request(:post, "#{BASE}/services/compute").to_return(status: 400, body: '{"message":"Insufficient balance"}') + + error = assert_raises(OctaSpace::ValidationError) { @client.compute.start(node_number: "A", image: "x") } + assert_equal "Insufficient balance", error.message + end + + def test_start_is_not_retried + stub_request(:post, "#{BASE}/services/compute").to_return(status: 503, body: "{}") + client = test_client(max_retries: 3) + + assert_raises(OctaSpace::ServiceUnavailableError) { client.compute.start(node_number: "A", image: "x") } + assert_requested :post, "#{BASE}/services/compute", times: 1 + end + + # --- start_batch --- + + def test_start_batch_assigns_positions_and_parses_results + stub_request(:post, "#{BASE}/services/compute") + .with(body: [{id: "one", node_number: "A", image: "x"}, {id: 1, node_number: "B", image: "x"}].to_json) + .to_return(status: 200, body: fixture("next/batch.json")) + + results = @client.compute.start_batch([{id: "one", node_number: "A", image: "x"}, {node_number: "B", image: "x"}]) + assert_equal 3, results.size + assert results[0].started? + assert_equal "eeda4f74-0ccb-420e-b718-3f0ec56e397f", results[0].uuid + assert results[1].failed? + assert_equal "two", results[1].id + assert_match(/node_number must be/, results[1].reason) + end + + def test_start_batch_rejects_duplicate_ids + error = assert_raises(ArgumentError) do + @client.compute.start_batch([{id: 1, node_number: "A", image: "x"}, {node_number: "B", image: "x"}, {node_number: "C", image: "x"}]) + end + assert_includes error.message, "duplicate batch id 1" + end + + def test_start_batch_rejects_empty_and_unknown + assert_raises(ArgumentError) { @client.compute.start_batch([]) } + assert_raises(ArgumentError) { @client.compute.start_batch([{node_number: "A", image: "x", node_id: 1}]) } + end + + # --- restart --- + + def test_restart_without_params_sends_no_body + stub_request(:post, "#{BASE}/services/compute/#{UUID}/restart") + .with { |req| req.body.to_s.empty? } + .to_return(status: 202, body: %({"uuid":"#{UUID}"})) + + assert_equal UUID, @client.compute.restart(UUID) + end + + def test_restart_with_params_sends_them + stub_request(:post, "#{BASE}/services/compute/#{UUID}/restart") + .with(body: {disk_size: 10, envs: {"K" => "V"}, entrypoint: "sh -c", args: "sleep 1"}.to_json) + .to_return(status: 202, body: "") + + assert_equal UUID, @client.compute.restart(UUID, disk_size: 10, envs: {"K" => "V"}, entrypoint: "sh -c", args: "sleep 1") + end + + def test_restart_rejects_image + assert_raises(ArgumentError) { @client.compute.restart(UUID, image: "other") } + end + + def test_restart_conflict_and_cooldown + stub_request(:post, "#{BASE}/services/compute/#{UUID}/restart").to_return( + {status: 409, body: '{"message":"Session is still starting or restarting"}'}, + {status: 429, body: '{"message":"Session was restarted too recently, retry in 288 seconds"}', + headers: {"Retry-After" => "288"}} + ) + + assert_raises(OctaSpace::ConflictError) { @client.compute.restart(UUID) } + error = assert_raises(OctaSpace::RateLimitError) { @client.compute.restart(UUID) } + assert_equal 288, error.retry_after + end +end diff --git a/test/octaspace/resources/render_test.rb b/test/octaspace/resources/render_test.rb new file mode 100644 index 0000000..73f0fb6 --- /dev/null +++ b/test/octaspace/resources/render_test.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "test_helper" + +class OctaSpace::RenderResourceTest < Minitest::Test + BASE = StubHelpers::BASE_URL + + def setup + super + @client = test_client + end + + def test_list_returns_render_machines + stub_get("/services/render", fixture_path: "next/services_render.json") + machines = @client.render.list + + assert(machines.all?(OctaSpace::Types::RenderMachine)) + m = machines.first + assert_equal "7R2S-S05E-S", m.node_number + assert_equal 100_000, m.base_price + assert_equal 5101, m.blender_score + refute_respond_to m, :storage_price + end + + def test_start_sends_only_node_number + stub_request(:post, "#{BASE}/services/render") + .with(body: '{"node_number":"7R2S-S05E-S"}') + .to_return(status: 201, body: '{"uuid":"r-1"}') + + assert_equal "r-1", @client.render.start(node_number: "7R2S-S05E-S") + end + + def test_start_503_is_service_unavailable + stub_request(:post, "#{BASE}/services/render").to_return(status: 503, body: '{"message":"Service is temporarily unavailable"}') + + error = assert_raises(OctaSpace::ServiceUnavailableError) { @client.render.start(node_number: "A") } + assert_equal "Service is temporarily unavailable", error.message + end + + def test_start_batch_reports_unavailable_per_item + stub_request(:post, "#{BASE}/services/render") + .with(body: '[{"id":0,"node_number":"7R2S-S05E-S"},{"id":1,"node_number":"ZZZZ-ZZZZ-Z"}]') + .to_return(status: 200, body: '[{"id":0,"status":1,"reason":"Service is temporarily unavailable"},' \ + '{"id":1,"status":1,"reason":"Invalid node number"}]') + + results = @client.render.start_batch([{node_number: "7R2S-S05E-S"}, {node_number: "ZZZZ-ZZZZ-Z"}]) + assert(results.all?(&:failed?)) + assert_equal "Service is temporarily unavailable", results.first.reason + end + + def test_start_batch_rejects_legacy_fields + assert_raises(ArgumentError) { @client.render.start_batch([{node_number: "A", disk_size: 100}]) } + end +end diff --git a/test/octaspace/resources/services_test.rb b/test/octaspace/resources/services_test.rb index 5ebfbe2..0fa6b6f 100644 --- a/test/octaspace/resources/services_test.rb +++ b/test/octaspace/resources/services_test.rb @@ -3,134 +3,11 @@ require "test_helper" class OctaSpace::ServicesResourceTest < Minitest::Test - # Request payloads from the removed playground presets. - PAYLOADS = { - "services.mr.create" => {node_id: 1, disk_size: 10, image: "ubuntu:24.04", app: "249b4cb3-3db1-4c06-98a4-772ba88cd81c"}, - "services.render.create" => {node_id: 1, disk_size: 100}, - "services.vpn.create" => {node_id: 1, subkind: "wg"}, - "services.session.stop" => {uuid: "sess-abc-123", score: 5} - }.freeze - def setup super @client = test_client end - # --- Services namespace --- - - def test_services_exposes_mr_vpn_render - assert_instance_of OctaSpace::Resources::Services::MachineRental, @client.services.mr - assert_instance_of OctaSpace::Resources::Services::Vpn, @client.services.vpn - assert_instance_of OctaSpace::Resources::Services::Render, @client.services.render - end - - def test_services_session_returns_proxy - proxy = @client.services.session("sess-abc-123") - assert_instance_of OctaSpace::Resources::Services::SessionProxy, proxy - end - - # --- MachineRental --- - - def test_mr_list_returns_array - stub_get("/services/mr", fixture_path: "services/mr/index.json") - response = @client.services.mr.list - assert response.success? - assert_kind_of Array, response.data - assert_equal 9010, response.data.first["node_id"] - assert_equal "France", response.data.first["country"] - assert_kind_of Hash, response.data.first["reliability"] - end - - def test_mr_create_returns_success - payload = PAYLOADS.fetch("services.mr.create").merge( - organization_id: 77, - project_id: 88 - ) - stub_request(:post, "#{StubHelpers::BASE_URL}/services/mr") - .with(body: [ - { - id: 0, - node_id: payload[:node_id], - disk_size: payload[:disk_size], - image: payload[:image], - app: payload[:app], - envs: {}, - ports: [], - http_ports: [], - start_command: "", - entrypoint: "", - organization_id: 77, - project_id: 88 - } - ].to_json) - .to_return(status: 201, body: '{"uuid":"new-sess","status":"starting"}', headers: json_headers) - - response = @client.services.mr.create(**payload) - assert response.success? - assert_equal "new-sess", response.data["uuid"] - end - - def test_mr_create_raises_api_error_on_400_object_rejection - payload = PAYLOADS.fetch("services.mr.create") - stub_request(:post, "#{StubHelpers::BASE_URL}/services/mr") - .to_return(status: 400, body: '{"message":"Node not found"}', headers: json_headers) - - assert_raises(OctaSpace::ApiError) { @client.services.mr.create(**payload) } - end - - def test_mr_create_raises_provision_rejected_error_on_batch_rejection - payload = PAYLOADS.fetch("services.mr.create") - stub_request(:post, "#{StubHelpers::BASE_URL}/services/mr") - .to_return( - status: 200, - body: '[{"id":0,"reason":"Node not found","status":1}]', - headers: json_headers - ) - - error = assert_raises(OctaSpace::ProvisionRejectedError) { @client.services.mr.create(**payload) } - assert_includes error.message, "Node not found" - assert_equal 1, error.rejections.length - end - - def test_mr_list_raises_not_found_on_404 - stub_error(:get, "/services/mr", status: 404, message: "Not Found") - assert_raises(OctaSpace::NotFoundError) { @client.services.mr.list } - end - - # --- VPN --- - - def test_vpn_list_returns_array - stub_get("/services/vpn", fixture_path: "services/vpn/index.json") - response = @client.services.vpn.list - assert response.success? - assert_kind_of Array, response.data - assert_equal 9539, response.data.first["node_id"] - refute response.data.first["residential"] - end - - def test_vpn_create_returns_success - payload = PAYLOADS.fetch("services.vpn.create") - stub_post("/services/vpn", status: 201, body: '{"uuid":"new-vpn","status":"starting"}') - response = @client.services.vpn.create(**payload) - assert response.success? - end - - # --- Render --- - - def test_render_list_returns_success - stub_get("/services/render", status: 200, fixture_path: "services/render/index.json") - response = @client.services.render.list - assert response.success? - assert_equal "NVIDIA RTX A6000", response.data.first["gpu"] - end - - def test_render_create_returns_success - payload = PAYLOADS.fetch("services.render.create") - stub_post("/services/render", status: 201, body: '{"uuid":"new-render","status":"queued"}') - response = @client.services.render.create(**payload) - assert response.success? - end - # --- SessionProxy --- def test_session_proxy_info @@ -160,9 +37,8 @@ def test_session_proxy_logs_with_recent_flag end def test_session_proxy_stop - payload = PAYLOADS.fetch("services.session.stop") stub_post("/services/sess-abc-123/stop", status: 200, body: '{"status":"stopped"}') - response = @client.services.session(payload[:uuid]).stop(score: payload[:score]) + response = @client.services.session("sess-abc-123").stop(score: 5) assert response.success? end