diff --git a/lib/octaspace.rb b/lib/octaspace.rb index f95b98c..de16fd4 100644 --- a/lib/octaspace.rb +++ b/lib/octaspace.rb @@ -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" diff --git a/lib/octaspace/client.rb b/lib/octaspace/client.rb index 3388d30..64f40e0 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, :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. @@ -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) diff --git a/lib/octaspace/errors.rb b/lib/octaspace/errors.rb index d4405b2..89fa18e 100644 --- a/lib/octaspace/errors.rb +++ b/lib/octaspace/errors.rb @@ -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. @@ -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 diff --git a/lib/octaspace/model.rb b/lib/octaspace/model.rb index 641cc4b..bc1fdf7 100644 --- a/lib/octaspace/model.rb +++ b/lib/octaspace/model.rb @@ -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] + def redacted_names + @redacted_names ||= [] + end + # @return [Array] declared attribute names def attribute_names @attribute_names ||= [] @@ -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. @@ -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 diff --git a/lib/octaspace/resources/services.rb b/lib/octaspace/resources/services.rb deleted file mode 100644 index 5ba8cf9..0000000 --- a/lib/octaspace/resources/services.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -module OctaSpace - module Resources - # Legacy session proxy: client.services.session("uuid").info / logs / stop - class Services < Base - # Return a proxy object for operations on a specific session - # @param uuid [String] session UUID - # @return [OctaSpace::Resources::Services::SessionProxy] - def session(uuid) - Services::SessionProxy.new(transport, uuid) - end - end - end -end - -require_relative "services/session_proxy" diff --git a/lib/octaspace/resources/services/session_proxy.rb b/lib/octaspace/resources/services/session_proxy.rb deleted file mode 100644 index b53f47b..0000000 --- a/lib/octaspace/resources/services/session_proxy.rb +++ /dev/null @@ -1,54 +0,0 @@ -# frozen_string_literal: true - -require "uri" - -module OctaSpace - module Resources - class Services - # Proxy object for operations on a specific service session - # - # Obtained via: client.services.session("uuid") - # - # @example - # session = client.services.session("abc-123") - # session.info - # session.logs - # session.stop(score: 5) - class SessionProxy - # @param transport [OctaSpace::Transport::Base] - # @param uuid [String] session UUID - def initialize(transport, uuid) - @transport = transport - @uuid = URI.encode_www_form_component(uuid.to_s) - end - - # Fetch session details - # GET /services/:uuid/info - # @return [OctaSpace::Response] - def info - @transport.get("/services/#{@uuid}/info") - end - - # Fetch session logs - # GET /services/:uuid/logs - # @param recent [Boolean, nil] use the recent logs branch for finished sessions - # @return [OctaSpace::Response] - def logs(recent: nil) - params = {} - params[:recent] = true if recent - return @transport.get("/services/#{@uuid}/logs") if params.empty? - - @transport.get("/services/#{@uuid}/logs", params:) - end - - # Stop the session - # POST /services/:uuid/stop - # @param params [Hash] e.g. { score: 5 } - # @return [OctaSpace::Response] - def stop(**params) - @transport.post("/services/#{@uuid}/stop", body: params.empty? ? nil : params, idempotent: true) - end - end - end - end -end diff --git a/lib/octaspace/resources/sessions.rb b/lib/octaspace/resources/sessions.rb index babeaa3..ac66f53 100644 --- a/lib/octaspace/resources/sessions.rb +++ b/lib/octaspace/resources/sessions.rb @@ -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] 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 diff --git a/lib/octaspace/types/session.rb b/lib/octaspace/types/session.rb index 0512ea3..bbe00f5 100644 --- a/lib/octaspace/types/session.rb +++ b/lib/octaspace/types/session.rb @@ -2,10 +2,147 @@ module OctaSpace module Types - # Value object representing a service session - Session = Data.define(:uuid, :service, :app_name, :node_id, :urls, :prices, :node_hw) do - # @return [String] - def to_s = "Session <#{uuid}> #{service}" + # Converts Unix milliseconds to Time + # @api private + def self.time_ms(value) = value.is_a?(Numeric) ? Time.at(value / 1000.0) : nil + + # An SSH endpoint of a session + # + # +user+ comes from the image's USER, so the image author controls it. + # Before running +ssh+ with these values, check them and pass them as + # separate arguments after "--" (+ssh -p PORT -l USER -- HOST+), so a + # value like "-oProxyCommand=…" cannot run a local command. + class SshEndpoint < Model + attribute :host, :port, :user + end + + # SSH access of a compute session + class SessionSsh < Model + # @!attribute [r] direct + # @return [SshEndpoint, nil] direct access; only when the account has an SSH key + # @!attribute [r] proxy + # @return [SshEndpoint, nil] access through the platform's SSH proxy + # @!attribute [r] web + # @return [String, nil] web terminal URL. It contains credentials: + # do not log it. + attribute :direct, :proxy, model: SshEndpoint + attribute :web + redact :web + + # @return [SshEndpoint, nil] direct access when available, otherwise the proxy + def endpoint = direct || proxy + end + + # Hardware of the node a session runs on, as recorded at its start + class NodeHw < Model + # @!attribute [r] total_memory + # @return [Integer] bytes + # @!attribute [r] disk + # @return [Integer] rented disk, GB + # @!attribute [r] net_down_mbps + # @return [Integer, nil] Mbit/s; nil for sessions started before the API reported it + attribute :cpu, :cpu_cores, :total_memory, :memory_speed, :memory_type, + :disk, :disk_type, :disk_speed_mbs, :gpu_vendor, :net_down_mbps, :net_up_mbps + attribute :gpus, model: Gpu, many: true + end + + # A running session (+GET /sessions+, +GET /services/{uuid}/info+). + # + # {.build} returns a {ComputeSession} or a {RenderSession} by the +service+ + # field, and a plain Session for a service this SDK does not know yet. + # Many fields are nil while the session is starting. + class Session < Model + # @!attribute [r] service + # @return [String] "compute", "render", or a service added later + # @!attribute [r] status + # @return [String] "starting" or "ready" + # @!attribute [r] progress + # @return [String, nil] current configuration step + # @!attribute [r] pull_percent + # @return [Integer, nil] image pull progress, 0..100 (an estimate) + # @!attribute [r] duration + # @return [Integer] seconds since the start + # @!attribute [r] started_at + # @return [Integer, nil] Unix milliseconds; see {#started_time} + # @!attribute [r] base_price + # @return [Integer, nil] price per hour fixed at the start, micro-USD + # @!attribute [r] charge_amount + # @return [Integer, nil] charged so far, micro-USD + attribute :service, :uuid, :status, :progress, :pull_percent, :duration, :started_at, + :node_number, :base_price, :charge_amount, :app_name, :app_logo + attribute :node_hw, model: NodeHw + + # @param data [Hash] a session JSON object + # @return [Session] the subclass matching +service+ + def self.build(data) + return data unless data.is_a?(Hash) + + klass = case data["service"] + when "compute" then ComputeSession + when "render" then RenderSession + else Session + end + klass.new(data) + end + + def ready? = status == "ready" + def starting? = status == "starting" + def compute? = service == "compute" + def render? = service == "render" + + # @return [Time, nil] {#started_at} as a Time + def started_time = Types.time_ms(started_at) + end + + # A running compute session + class ComputeSession < Session + # @!attribute [r] ports + # @return [Hash{String=>Integer}] container port => public port + # @!attribute [r] urls + # @return [Hash{String=>String}] HTTP port => URL + # @!attribute [r] fm + # @return [String, nil] web file manager URL; it needs no login, so treat it as a secret + # @!attribute [r] envs + # @return [Hash{String=>String}] environment variables of the container + # @!attribute [r] rx + # @return [Integer] bytes received + # @!attribute [r] tx + # @return [Integer] bytes sent + attribute :public_ip, :city, :country, :country_iso, :ports, :urls, :fm, :envs, + :rx, :tx, :storage_price, :traffic_price, :min_rent_minutes + attribute :ssh, model: SessionSsh + redact :fm + end + + # A running render session; billed by the hour until stopped + class RenderSession < Session + end + + # A platform event of a session's system log + class LogEntry < Model + # @!attribute [r] msg + # @return [String] may span several lines + # @!attribute [r] ts + # @return [Integer, nil] Unix milliseconds; see {#time} + attribute :msg, :ts + + # @return [Time, nil] + def time = Types.time_ms(ts) + end + + # Logs of a running compute session (+GET /services/{uuid}/logs+) + class SessionLogs < Model + # @!attribute [r] system + # @return [Array] platform events, oldest first + # @!attribute [r] container + # @return [String, nil] the latest container output (up to 1 MiB, may + # contain "\r\n"); nil while the node does not report it + # @!attribute [r] container_truncated + # @return [Boolean] the output was cut to its last 1 MiB + attribute :system, model: LogEntry, many: true + attribute :container, :container_truncated + + def container_truncated? = container_truncated == true end end end diff --git a/test/fixtures/next/session_compute_net_mbps.json b/test/fixtures/next/session_compute_net_mbps.json new file mode 100644 index 0000000..da49853 --- /dev/null +++ b/test/fixtures/next/session_compute_net_mbps.json @@ -0,0 +1,57 @@ +{ + "ports": {}, + "status": "ready", + "progress": "Service configured", + "service": "compute", + "started_at": 1793640731705, + "ssh": { + "proxy": null, + "web": null, + "direct": null + }, + "duration": 2, + "city": "Mukachevo", + "country": "Ukraine", + "tx": 0, + "rx": 0, + "uuid": "4c6d8e2f-1a3b-4c5d-9e7f-8a9b0c1d2e3f", + "traffic_price": 100, + "storage_price": 100, + "node_number": "WJFM-MYWR-9", + "min_rent_minutes": 0, + "base_price": 100, + "fm": null, + "envs": { + "SDK_FIXTURE": "1" + }, + "urls": {}, + "pull_percent": null, + "public_ip": "203.0.113.11", + "node_hw": { + "cpu": "AMD Ryzen 7 7700 8-Core Processor", + "net_up_mbps": 465, + "net_down_mbps": 257, + "memory_type": "DDR5", + "memory_speed": 5600, + "gpus": [ + { + "model": "Arc B580", + "mem_total_mb": 12288 + }, + { + "model": "Arc B580", + "mem_total_mb": 12288 + } + ], + "gpu_vendor": "intel", + "disk_type": "nvme", + "disk_speed_mbs": 6580, + "disk": 1, + "cpu_cores": 16, + "total_memory": 97915236352 + }, + "country_iso": "UA", + "charge_amount": 0, + "app_name": "alpine:3.20", + "app_logo": "https://files.octa.space/apps-logo/docker.svg" +} \ No newline at end of file diff --git a/test/fixtures/next/session_compute_ready.json b/test/fixtures/next/session_compute_ready.json new file mode 100644 index 0000000..40cb257 --- /dev/null +++ b/test/fixtures/next/session_compute_ready.json @@ -0,0 +1,61 @@ +{ + "ports": {}, + "status": "ready", + "progress": "Service configured", + "service": "compute", + "started_at": 1793567847622, + "ssh": { + "proxy": null, + "web": null, + "direct": null + }, + "duration": 8, + "city": "Mukachevo", + "country": "Ukraine", + "tx": 0, + "rx": 0, + "uuid": "2a63b54f-f243-45ab-8416-2728e178a384", + "traffic_price": 100, + "storage_price": 100, + "node_number": "M95S-WZG8-7", + "min_rent_minutes": 0, + "base_price": 100, + "fm": null, + "envs": { + "SDK_AUDIT": "1" + }, + "urls": {}, + "pull_percent": null, + "public_ip": "203.0.113.10", + "node_hw": { + "cpu": "AMD Ryzen Threadripper 7960X 24-Cores", + "net_up_mbps": 466, + "net_down_mbps": 240, + "memory_type": "DDR5", + "memory_speed": 4800, + "gpus": [ + { + "model": "NVIDIA GeForce RTX 3090", + "mem_total_mb": 24576 + }, + { + "model": "NVIDIA GeForce RTX 3090", + "mem_total_mb": 24576 + }, + { + "model": "NVIDIA GeForce RTX 3090", + "mem_total_mb": 24576 + } + ], + "gpu_vendor": "nvidia", + "disk_type": "nvme", + "disk_speed_mbs": 4757, + "disk": 1, + "cpu_cores": 48, + "total_memory": 132114866176 + }, + "country_iso": "UA", + "charge_amount": 0, + "app_name": "alpine:3.20", + "app_logo": "https://files.octa.space/apps-logo/docker.svg" +} \ No newline at end of file diff --git a/test/fixtures/next/session_compute_ssh_proxy.json b/test/fixtures/next/session_compute_ssh_proxy.json new file mode 100644 index 0000000..69d4df8 --- /dev/null +++ b/test/fixtures/next/session_compute_ssh_proxy.json @@ -0,0 +1,61 @@ +{ + "ports": {}, + "status": "ready", + "progress": "Service configured", + "service": "compute", + "started_at": 1793639533878, + "ssh": { + "proxy": { + "port": 30232, + "user": "root", + "host": "proxy.example.com" + }, + "web": "https://terminal.example.com/session", + "direct": null + }, + "duration": 130, + "city": "Mukachevo", + "country": "Ukraine", + "tx": 603853, + "rx": 48245888, + "uuid": "9b2f6c1e-3d4a-4f5b-8c7d-0e1f2a3b4c5d", + "traffic_price": 100, + "storage_price": 100, + "node_number": "M95S-WZG8-7", + "min_rent_minutes": 0, + "base_price": 100, + "fm": null, + "envs": {}, + "urls": {}, + "pull_percent": null, + "public_ip": "203.0.113.10", + "node_hw": { + "cpu": "AMD Ryzen Threadripper 7960X 24-Cores", + "memory_type": "DDR5", + "memory_speed": 4800, + "gpus": [ + { + "model": "NVIDIA GeForce RTX 3090", + "mem_total_mb": 24576 + }, + { + "model": "NVIDIA GeForce RTX 3090", + "mem_total_mb": 24576 + }, + { + "model": "NVIDIA GeForce RTX 3090", + "mem_total_mb": 24576 + } + ], + "gpu_vendor": "nvidia", + "disk_type": "nvme", + "disk_speed_mbs": 4757, + "disk": 10, + "cpu_cores": 48, + "total_memory": 132114866176 + }, + "country_iso": "UA", + "charge_amount": 22, + "app_name": "Ubuntu 24", + "app_logo": "https://files.octa.space/apps-logo/docker.svg" +} \ No newline at end of file diff --git a/test/fixtures/next/session_compute_starting.json b/test/fixtures/next/session_compute_starting.json new file mode 100644 index 0000000..962e619 --- /dev/null +++ b/test/fixtures/next/session_compute_starting.json @@ -0,0 +1,35 @@ +{ + "ports": {}, + "status": "starting", + "progress": "Prepare node", + "service": "compute", + "started_at": 1793567847622, + "ssh": { + "proxy": null, + "web": null, + "direct": null + }, + "duration": 36, + "city": null, + "country": null, + "tx": 126, + "rx": 946, + "uuid": "2a63b54f-f243-45ab-8416-2728e178a384", + "traffic_price": null, + "storage_price": null, + "node_number": "M95S-WZG8-7", + "min_rent_minutes": null, + "base_price": null, + "fm": null, + "envs": { + "SDK_AUDIT": "2" + }, + "urls": {}, + "pull_percent": null, + "public_ip": "203.0.113.10", + "node_hw": null, + "country_iso": null, + "charge_amount": 0, + "app_name": null, + "app_logo": null +} \ No newline at end of file diff --git a/test/fixtures/next/session_logs.json b/test/fixtures/next/session_logs.json new file mode 100644 index 0000000..6d4b07b --- /dev/null +++ b/test/fixtures/next/session_logs.json @@ -0,0 +1,26 @@ +{ + "system": [ + { + "msg": "Pulling from library/alpine\nDigest: sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc\nStatus: Image is up to date for alpine:3.20\n", + "ts": 1793568008396 + }, + { + "msg": "start container, image: alpine:3.20", + "ts": 1793568010806 + }, + { + "msg": "restart with new parameters", + "ts": 1793568018510 + }, + { + "msg": "Pulling from library/alpine\nDigest: sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc\nStatus: Image is up to date for alpine:3.20\n", + "ts": 1793568019214 + }, + { + "msg": "start container, image: alpine:3.20", + "ts": 1793568020607 + } + ], + "container_truncated": false, + "container": "restarted-ok\r\n" +} \ No newline at end of file diff --git a/test/fixtures/services/session/info.json b/test/fixtures/services/session/info.json deleted file mode 100644 index 3356498..0000000 --- a/test/fixtures/services/session/info.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "uuid": "sess-abc-123", - "service": "mr", - "status": "running", - "node_id": 1, - "app_name": "Stable Diffusion", - "started_at": "2026-04-11T08:00:00Z", - "urls": {"jupyter": "https://sess-abc-123.octa.space"} -} diff --git a/test/fixtures/services/session/logs.json b/test/fixtures/services/session/logs.json deleted file mode 100644 index a7c87b9..0000000 --- a/test/fixtures/services/session/logs.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "system": [ - { - "ts": 1776161866508, - "msg": "24.04: Pulling from library/ubuntu\nDigest: sha256:84e77dee7d1bc93fb029a45e3c6cb9d8aa4831ccfcc7103d36e876938d28895b\nStatus: Image is up to date for ubuntu:24.04\n" - }, - { - "ts": 1776161866701, - "msg": "Container started\n" - } - ], - "container": "" -} diff --git a/test/fixtures/sessions/index.json b/test/fixtures/sessions/index.json deleted file mode 100644 index e8d7220..0000000 --- a/test/fixtures/sessions/index.json +++ /dev/null @@ -1,10 +0,0 @@ -[ - { - "uuid": "sess-abc-123", - "service": "mr", - "app_name": "Stable Diffusion", - "node_id": 1, - "urls": {"jupyter": "https://sess-abc-123.octa.space"}, - "prices": {"gpu_hour": 0.5} - } -] diff --git a/test/fixtures/sessions/recent.json b/test/fixtures/sessions/recent.json deleted file mode 100644 index 9fcdfcf..0000000 --- a/test/fixtures/sessions/recent.json +++ /dev/null @@ -1,13 +0,0 @@ -[ - { - "uuid": "5dd2d308-db91-4bda-86ff-e512d711b363", - "service": "mr", - "duration": "28", - "rx": "0", - "tx": "0", - "started_at": 1776161866303, - "finished_at": 1776161893995, - "charge_amount": 0, - "urls": {"8080": "https://sess-abc-123.octa.space"} - } -] diff --git a/test/octaspace/client_test.rb b/test/octaspace/client_test.rb index 6b25a0d..d7ea647 100644 --- a/test/octaspace/client_test.rb +++ b/test/octaspace/client_test.rb @@ -10,7 +10,8 @@ def test_initializes_all_resources assert_instance_of OctaSpace::Resources::Sessions, client.sessions assert_instance_of OctaSpace::Resources::Apps, client.apps assert_instance_of OctaSpace::Resources::Network, client.network - assert_instance_of OctaSpace::Resources::Services, client.services + assert_instance_of OctaSpace::Resources::Compute, client.compute + assert_instance_of OctaSpace::Resources::Render, client.render assert_instance_of OctaSpace::Resources::IdleJobs, client.idle_jobs end diff --git a/test/octaspace/resources/services_test.rb b/test/octaspace/resources/services_test.rb deleted file mode 100644 index 0fa6b6f..0000000 --- a/test/octaspace/resources/services_test.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -class OctaSpace::ServicesResourceTest < Minitest::Test - def setup - super - @client = test_client - end - - # --- SessionProxy --- - - def test_session_proxy_info - stub_get("/services/sess-abc-123/info", fixture_path: "services/session/info.json") - response = @client.services.session("sess-abc-123").info - assert response.success? - assert_equal "sess-abc-123", response.data["uuid"] - assert_equal "running", response.data["status"] - end - - def test_session_proxy_logs - stub_get("/services/sess-abc-123/logs", fixture_path: "services/session/logs.json") - response = @client.services.session("sess-abc-123").logs - assert response.success? - assert_kind_of Hash, response.data - assert_kind_of Array, response.data["system"] - end - - def test_session_proxy_logs_with_recent_flag - stub_request(:get, "#{StubHelpers::BASE_URL}/services/sess-abc-123/logs") - .with(query: {"recent" => "true"}) - .to_return(status: 200, body: fixture("services/session/logs.json"), headers: json_headers) - - response = @client.services.session("sess-abc-123").logs(recent: true) - assert response.success? - assert_equal "", response.data["container"] - end - - def test_session_proxy_stop - stub_post("/services/sess-abc-123/stop", status: 200, body: '{"status":"stopped"}') - response = @client.services.session("sess-abc-123").stop(score: 5) - assert response.success? - end - - def test_session_proxy_stop_without_params - stub_post("/services/sess-abc-123/stop", status: 200, body: '{"status":"stopped"}') - response = @client.services.session("sess-abc-123").stop - assert response.success? - end - - def test_session_proxy_raises_not_found - stub_error(:get, "/services/bad-uuid/info", status: 404, message: "Session not found") - assert_raises(OctaSpace::NotFoundError) { @client.services.session("bad-uuid").info } - end -end diff --git a/test/octaspace/resources/sessions_test.rb b/test/octaspace/resources/sessions_test.rb index 426c850..caeb5c6 100644 --- a/test/octaspace/resources/sessions_test.rb +++ b/test/octaspace/resources/sessions_test.rb @@ -3,31 +3,175 @@ require "test_helper" class OctaSpace::SessionsResourceTest < Minitest::Test + BASE = StubHelpers::BASE_URL + UUID = "9b2f6c1e-3d4a-4f5b-8c7d-0e1f2a3b4c5d" + def setup super + @sleeps = [] @client = test_client + @sessions = OctaSpace::Resources::Sessions.new(@client.instance_variable_get(:@transport), sleeper: ->(s) { @sleeps << s }) + end + + def info_url = "#{BASE}/services/#{UUID}/info" + + # --- list / get --- + + def test_list_builds_sessions_by_service + body = [JSON.parse(fixture("next/session_compute_ready.json")), + {"service" => "render", "uuid" => "r", "status" => "ready", "duration" => 1}, + {"service" => "vpn", "uuid" => "v", "status" => "ready", "duration" => 1, "config" => "x"}] + stub_request(:get, "#{BASE}/sessions").to_return(status: 200, body: body.to_json) + + compute, render, vpn = @client.sessions.list + assert_instance_of OctaSpace::Types::ComputeSession, compute + assert_instance_of OctaSpace::Types::RenderSession, render + assert_instance_of OctaSpace::Types::Session, vpn + assert vpn.ready? + assert_equal "x", vpn[:config] + end + + def test_list_requires_api_key + assert_raises(OctaSpace::AuthenticationError) { OctaSpace::Client.new.sessions.list } + end + + def test_get_parses_a_ready_compute_session + stub_request(:get, info_url).to_return(status: 200, body: fixture("next/session_compute_ssh_proxy.json")) + s = @client.sessions.get(UUID) + + assert s.compute? + assert s.ready? + assert_equal "M95S-WZG8-7", s.node_number + assert_equal 22, s.charge_amount + assert_equal Time.at(1_793_639_533.878), s.started_time + assert_nil s.ssh.direct + assert_equal "proxy.example.com", s.ssh.endpoint.host + assert_equal 30232, s.ssh.endpoint.port + assert_equal "root", s.ssh.proxy.user + assert_equal 10, s.node_hw.disk + assert_equal "NVIDIA GeForce RTX 3090", s.node_hw.gpus.first.model + assert_equal({}, s.envs) + end + + def test_starting_session_has_nil_fields + stub_request(:get, info_url).to_return(status: 200, body: fixture("next/session_compute_starting.json")) + s = @client.sessions.get(UUID) + + assert s.starting? + refute s.ready? + end + + def test_net_speed_in_node_hw + stub_request(:get, info_url).to_return(status: 200, body: fixture("next/session_compute_net_mbps.json")) + hw = @client.sessions.get(UUID).node_hw + + assert_kind_of Integer, hw.net_down_mbps + assert_kind_of Integer, hw.net_up_mbps + end + + def test_inspect_redacts_access_urls + data = JSON.parse(fixture("next/session_compute_ssh_proxy.json")) + data["fm"] = "https://fm.example/secret" + data["ssh"]["web"] = "https://user:pass@term.example" + stub_request(:get, info_url).to_return(status: 200, body: data.to_json) + s = @client.sessions.get(UUID) + + refute_includes s.inspect, "fm.example" + refute_includes s.ssh.inspect, "user:pass" + assert_includes s.ssh.inspect, "[REDACTED]" + assert_equal "https://user:pass@term.example", s.ssh.web + end + + def test_get_404_and_400 + stub_request(:get, info_url).to_return(status: 404, body: '{"message":"Session not found"}') + stub_request(:get, "#{BASE}/services/not-a-uuid/info").to_return(status: 400, body: '{"message":"Invalid session uuid"}') + + assert_raises(OctaSpace::NotFoundError) { @client.sessions.get(UUID) } + assert_raises(OctaSpace::ValidationError) { @client.sessions.get("not-a-uuid") } end - def test_list_returns_array - stub_get("/sessions", fixture_path: "sessions/index.json") - response = @client.sessions.list - assert response.success? - assert_kind_of Array, response.data - assert_equal "sess-abc-123", response.data.first["uuid"] + def test_uuid_is_path_encoded + stub_request(:get, "#{BASE}/services/..%2Fx/info").to_return(status: 400, body: "{}") + assert_raises(OctaSpace::ValidationError) { @client.sessions.get("../x") } end - def test_list_passes_params - stub_request(:get, "#{StubHelpers::BASE_URL}/sessions") - .with(query: {"recent" => "true"}) - .to_return(status: 200, body: fixture("sessions/recent.json"), headers: json_headers) - response = @client.sessions.list(recent: true) - assert response.success? - assert_equal "28", response.data.first["duration"] - assert_equal "0", response.data.first["rx"] + # --- logs --- + + def test_logs + stub_request(:get, "#{BASE}/services/#{UUID}/logs").to_return(status: 200, body: fixture("next/session_logs.json")) + logs = @client.sessions.logs(UUID) + + assert_kind_of OctaSpace::Types::LogEntry, logs.system.first + assert_includes logs.system.first.msg, "Pulling" + assert_kind_of Time, logs.system.first.time + refute logs.container_truncated? + end + + def test_logs_tail_and_null_container + stub_request(:get, "#{BASE}/services/#{UUID}/logs").with(query: {"tail" => "5"}) + .to_return(status: 200, body: '{"system":[],"container":null,"container_truncated":false}') + logs = @client.sessions.logs(UUID, tail: 5) + + assert_nil logs.container + assert_equal [], logs.system + end + + # --- stop --- + + def test_stop_returns_uuid_and_sends_no_body + stub_request(:post, "#{BASE}/services/#{UUID}/stop").with { |r| r.body.to_s.empty? } + .to_return(status: 202, body: %({"uuid":"#{UUID}"})) + + assert_equal UUID, @client.sessions.stop(UUID) + end + + def test_stop_is_retried_on_transient_errors + client = test_client(max_retries: 1, retry_interval: 0) + stub_request(:post, "#{BASE}/services/#{UUID}/stop") + .to_return({status: 503, body: "{}"}, {status: 202, body: ""}) + + assert_equal UUID, client.sessions.stop(UUID) end - def test_list_raises_authentication_error_on_401 - stub_error(:get, "/sessions", status: 401, message: "Unauthorized") - assert_raises(OctaSpace::AuthenticationError) { @client.sessions.list } + # --- wait_ready / wait_stopped --- + + def test_wait_ready_polls_and_yields_progress + stub_request(:get, info_url).to_return( + {status: 200, body: fixture("next/session_compute_starting.json")}, + {status: 200, body: fixture("next/session_compute_ready.json")} + ) + seen = [] + session = @sessions.wait_ready(UUID, interval: 2) { |s| seen << s.status } + + assert session.ready? + assert_equal %w[starting ready], seen + assert_equal [2], @sleeps + end + + def test_wait_ready_raises_when_session_stops + stub_request(:get, info_url).to_return( + {status: 200, body: fixture("next/session_compute_starting.json")}, + {status: 404, body: '{"message":"Session not found"}'} + ) + + error = assert_raises(OctaSpace::SessionStoppedError) { @sessions.wait_ready(UUID) } + assert_kind_of OctaSpace::NotFoundError, error + assert_includes error.message, "stopped before it became ready" + end + + def test_wait_ready_times_out + stub_request(:get, info_url).to_return(status: 200, body: fixture("next/session_compute_starting.json")) + + assert_raises(OctaSpace::WaitTimeoutError) { @sessions.wait_ready(UUID, timeout: 1, interval: 5) } + end + + def test_wait_stopped + stub_request(:get, info_url).to_return( + {status: 200, body: fixture("next/session_compute_ready.json")}, + {status: 404, body: '{"message":"Session not found"}'} + ) + + assert_equal true, @sessions.wait_stopped(UUID, interval: 1) + assert_equal [1], @sleeps end end diff --git a/test/octaspace/types_test.rb b/test/octaspace/types_test.rb index b3cf330..2ed6b4f 100644 --- a/test/octaspace/types_test.rb +++ b/test/octaspace/types_test.rb @@ -55,19 +55,4 @@ def test_account_to_s ) assert_equal "OctaSpace Account ", account.to_s end - - # --- Session --- - - def test_session_to_s - session = OctaSpace::Types::Session.new( - uuid: "sess-abc", service: "mr", app_name: "SD", - node_id: 1, urls: {}, prices: {}, node_hw: nil - ) - assert_equal "Session mr", session.to_s - end - - def test_session_equality - attrs = {uuid: "sess-1", service: "mr", app_name: "SD", node_id: 1, urls: {}, prices: {}, node_hw: nil} - assert_equal OctaSpace::Types::Session.new(**attrs), OctaSpace::Types::Session.new(**attrs) - end end