From ecd40e24911994a75a8f3c866be2d0d25c18448b Mon Sep 17 00:00:00 2001 From: secretpray Date: Fri, 6 Nov 2026 12:07:57 +0200 Subject: [PATCH 1/4] test: use placeholder node numbers and locations in fixtures --- test/fixtures/batch.json | 2 +- test/fixtures/hosting_nodes.json | 30 +++++++++---------- test/fixtures/services_compute.json | 18 +++++------ test/fixtures/services_render.json | 12 ++++---- test/fixtures/session_compute_net_mbps.json | 8 ++--- test/fixtures/session_compute_ready.json | 12 ++++---- test/fixtures/session_compute_ssh_proxy.json | 8 ++--- test/fixtures/session_compute_starting.json | 6 ++-- test/fixtures/stats_gpu_locations.json | 16 +++++----- test/octaspace/resources/compute_test.rb | 10 +++---- test/octaspace/resources/hosting_test.rb | 8 ++--- test/octaspace/resources/render_test.rb | 10 +++---- test/octaspace/resources/sessions_test.rb | 2 +- test/octaspace/resources/stats_test.rb | 2 +- .../transport/faraday_transport_test.rb | 4 +-- 15 files changed, 74 insertions(+), 74 deletions(-) diff --git a/test/fixtures/batch.json b/test/fixtures/batch.json index 8c4001e..d0876bd 100644 --- a/test/fixtures/batch.json +++ b/test/fixtures/batch.json @@ -2,7 +2,7 @@ { "id": 1, "status": 0, - "uuid": "eeda4f74-0ccb-420e-b718-3f0ec56e397f" + "uuid": "8e1d4c7b-2a9f-4e3d-b6c5-0f1a2b3c4d5e" }, { "id": "two", diff --git a/test/fixtures/hosting_nodes.json b/test/fixtures/hosting_nodes.json index e4d97b0..01bd39b 100644 --- a/test/fixtures/hosting_nodes.json +++ b/test/fixtures/hosting_nodes.json @@ -67,12 +67,12 @@ "cpu_load_percent": 0 }, "ip": "203.0.113.10", - "number": "M95S-WZG8-7", + "number": "7KQ4-M2XD-4", "location": { - "city": "Mukachevo", - "country": "Ukraine", - "latitude": 48.4425, - "longitude": 22.718 + "city": "Frankfurt", + "country": "Germany", + "latitude": 50.1109, + "longitude": 8.6821 }, "services": { "render": { @@ -136,12 +136,12 @@ "cpu_load_percent": 0 }, "ip": "203.0.113.11", - "number": "7R2S-S05E-S", + "number": "3HW9-P6TR-8", "location": { - "city": "Mukachevo", - "country": "Ukraine", - "latitude": 48.4425, - "longitude": 22.718 + "city": "Frankfurt", + "country": "Germany", + "latitude": 50.1109, + "longitude": 8.6821 }, "services": { "render": { @@ -218,12 +218,12 @@ "cpu_load_percent": 0 }, "ip": "203.0.113.12", - "number": "WJFM-MYWR-9", + "number": "5NBT-C8VJ-2", "location": { - "city": "Mukachevo", - "country": "Ukraine", - "latitude": 48.4425, - "longitude": 22.718 + "city": "Frankfurt", + "country": "Germany", + "latitude": 50.1109, + "longitude": 8.6821 }, "services": { "compute": { diff --git a/test/fixtures/services_compute.json b/test/fixtures/services_compute.json index 7882463..2cc1ffb 100644 --- a/test/fixtures/services_compute.json +++ b/test/fixtures/services_compute.json @@ -9,11 +9,11 @@ "s_normal": 44, "stability": 100.0 }, - "city": "Mukachevo", - "country": "Ukraine", + "city": "Frankfurt", + "country": "Germany", "traffic_price": 100, "storage_price": 0, - "node_number": "7R2S-S05E-S", + "node_number": "3HW9-P6TR-8", "net_up_mbps": 466, "net_down_mbps": 222, "min_rent_minutes": 10, @@ -54,11 +54,11 @@ "s_normal": 14, "stability": 100.0 }, - "city": "Mukachevo", - "country": "Ukraine", + "city": "Frankfurt", + "country": "Germany", "traffic_price": 100, "storage_price": 100, - "node_number": "M95S-WZG8-7", + "node_number": "7KQ4-M2XD-4", "net_up_mbps": 466, "net_down_mbps": 240, "min_rent_minutes": 0, @@ -119,11 +119,11 @@ "s_normal": 2, "stability": 100.0 }, - "city": "Mukachevo", - "country": "Ukraine", + "city": "Frankfurt", + "country": "Germany", "traffic_price": 100, "storage_price": 100, - "node_number": "WJFM-MYWR-9", + "node_number": "5NBT-C8VJ-2", "net_up_mbps": 466, "net_down_mbps": 241, "min_rent_minutes": 0, diff --git a/test/fixtures/services_render.json b/test/fixtures/services_render.json index 26ae8d4..2133365 100644 --- a/test/fixtures/services_render.json +++ b/test/fixtures/services_render.json @@ -9,9 +9,9 @@ "s_normal": 44, "stability": 100.0 }, - "city": "Mukachevo", - "country": "Ukraine", - "node_number": "7R2S-S05E-S", + "city": "Frankfurt", + "country": "Germany", + "node_number": "3HW9-P6TR-8", "net_up_mbps": 466, "net_down_mbps": 222, "min_rent_minutes": 10, @@ -45,9 +45,9 @@ "s_normal": 14, "stability": 100.0 }, - "city": "Mukachevo", - "country": "Ukraine", - "node_number": "M95S-WZG8-7", + "city": "Frankfurt", + "country": "Germany", + "node_number": "7KQ4-M2XD-4", "net_up_mbps": 466, "net_down_mbps": 240, "min_rent_minutes": 0, diff --git a/test/fixtures/session_compute_net_mbps.json b/test/fixtures/session_compute_net_mbps.json index da49853..98b382f 100644 --- a/test/fixtures/session_compute_net_mbps.json +++ b/test/fixtures/session_compute_net_mbps.json @@ -10,14 +10,14 @@ "direct": null }, "duration": 2, - "city": "Mukachevo", - "country": "Ukraine", + "city": "Frankfurt", + "country": "Germany", "tx": 0, "rx": 0, "uuid": "4c6d8e2f-1a3b-4c5d-9e7f-8a9b0c1d2e3f", "traffic_price": 100, "storage_price": 100, - "node_number": "WJFM-MYWR-9", + "node_number": "5NBT-C8VJ-2", "min_rent_minutes": 0, "base_price": 100, "fm": null, @@ -50,7 +50,7 @@ "cpu_cores": 16, "total_memory": 97915236352 }, - "country_iso": "UA", + "country_iso": "DE", "charge_amount": 0, "app_name": "alpine:3.20", "app_logo": "https://files.octa.space/apps-logo/docker.svg" diff --git a/test/fixtures/session_compute_ready.json b/test/fixtures/session_compute_ready.json index 40cb257..3e5b509 100644 --- a/test/fixtures/session_compute_ready.json +++ b/test/fixtures/session_compute_ready.json @@ -10,19 +10,19 @@ "direct": null }, "duration": 8, - "city": "Mukachevo", - "country": "Ukraine", + "city": "Frankfurt", + "country": "Germany", "tx": 0, "rx": 0, - "uuid": "2a63b54f-f243-45ab-8416-2728e178a384", + "uuid": "5f3c2b1a-7d6e-4f8a-9b0c-1d2e3f4a5b6c", "traffic_price": 100, "storage_price": 100, - "node_number": "M95S-WZG8-7", + "node_number": "7KQ4-M2XD-4", "min_rent_minutes": 0, "base_price": 100, "fm": null, "envs": { - "SDK_AUDIT": "1" + "APP_ENV": "1" }, "urls": {}, "pull_percent": null, @@ -54,7 +54,7 @@ "cpu_cores": 48, "total_memory": 132114866176 }, - "country_iso": "UA", + "country_iso": "DE", "charge_amount": 0, "app_name": "alpine:3.20", "app_logo": "https://files.octa.space/apps-logo/docker.svg" diff --git a/test/fixtures/session_compute_ssh_proxy.json b/test/fixtures/session_compute_ssh_proxy.json index 69d4df8..2ef0e98 100644 --- a/test/fixtures/session_compute_ssh_proxy.json +++ b/test/fixtures/session_compute_ssh_proxy.json @@ -14,14 +14,14 @@ "direct": null }, "duration": 130, - "city": "Mukachevo", - "country": "Ukraine", + "city": "Frankfurt", + "country": "Germany", "tx": 603853, "rx": 48245888, "uuid": "9b2f6c1e-3d4a-4f5b-8c7d-0e1f2a3b4c5d", "traffic_price": 100, "storage_price": 100, - "node_number": "M95S-WZG8-7", + "node_number": "7KQ4-M2XD-4", "min_rent_minutes": 0, "base_price": 100, "fm": null, @@ -54,7 +54,7 @@ "cpu_cores": 48, "total_memory": 132114866176 }, - "country_iso": "UA", + "country_iso": "DE", "charge_amount": 22, "app_name": "Ubuntu 24", "app_logo": "https://files.octa.space/apps-logo/docker.svg" diff --git a/test/fixtures/session_compute_starting.json b/test/fixtures/session_compute_starting.json index 962e619..2faa19c 100644 --- a/test/fixtures/session_compute_starting.json +++ b/test/fixtures/session_compute_starting.json @@ -14,15 +14,15 @@ "country": null, "tx": 126, "rx": 946, - "uuid": "2a63b54f-f243-45ab-8416-2728e178a384", + "uuid": "5f3c2b1a-7d6e-4f8a-9b0c-1d2e3f4a5b6c", "traffic_price": null, "storage_price": null, - "node_number": "M95S-WZG8-7", + "node_number": "7KQ4-M2XD-4", "min_rent_minutes": null, "base_price": null, "fm": null, "envs": { - "SDK_AUDIT": "2" + "APP_ENV": "2" }, "urls": {}, "pull_percent": null, diff --git a/test/fixtures/stats_gpu_locations.json b/test/fixtures/stats_gpu_locations.json index a8cb635..0f505d2 100644 --- a/test/fixtures/stats_gpu_locations.json +++ b/test/fixtures/stats_gpu_locations.json @@ -1,22 +1,22 @@ [ { - "city": "Mukachevo", - "country": "Ukraine", + "city": "Frankfurt", + "country": "Germany", "verified": 2, "gpus": 2, "rented": 0, "model": "Arc B580", - "longitude": 22.718, - "latitude": 48.4425 + "longitude": 8.6821, + "latitude": 50.1109 }, { - "city": "Mukachevo", - "country": "Ukraine", + "city": "Frankfurt", + "country": "Germany", "verified": 4, "gpus": 4, "rented": 1, "model": "NVIDIA GeForce RTX 3090", - "longitude": 22.718, - "latitude": 48.4425 + "longitude": 8.6821, + "latitude": 50.1109 } ] \ No newline at end of file diff --git a/test/octaspace/resources/compute_test.rb b/test/octaspace/resources/compute_test.rb index 8a4908a..33ac0f6 100644 --- a/test/octaspace/resources/compute_test.rb +++ b/test/octaspace/resources/compute_test.rb @@ -4,7 +4,7 @@ class OctaSpace::ComputeResourceTest < Minitest::Test BASE = StubHelpers::BASE_URL - UUID = "2a63b54f-f243-45ab-8416-2728e178a384" + UUID = "5f3c2b1a-7d6e-4f8a-9b0c-1d2e3f4a5b6c" def setup super @@ -18,7 +18,7 @@ def test_list_returns_machines_from_live_fixture machines = @client.compute.list assert_equal 3, machines.size - m = machines.find { |x| x.node_number == "M95S-WZG8-7" } + m = machines.find { |x| x.node_number == "7KQ4-M2XD-4" } assert_instance_of OctaSpace::Types::ComputeMachine, m assert_equal 100, m.base_price assert_equal 100, m.storage_price @@ -73,11 +73,11 @@ def test_unknown_fields_are_kept 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"}, + .with(body: {node_number: "7KQ4-M2XD-4", 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, + uuid = @client.compute.start(node_number: "7KQ4-M2XD-4", image: "alpine:3.20", disk_size: 1, envs: {"A" => "1"}, http_ports: [8080], ssh: false, fm: nil) assert_equal UUID, uuid end @@ -117,7 +117,7 @@ def test_start_batch_assigns_positions_and_parses_results 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_equal "8e1d4c7b-2a9f-4e3d-b6c5-0f1a2b3c4d5e", results[0].uuid assert results[1].failed? assert_equal "two", results[1].id assert_match(/node_number must be/, results[1].reason) diff --git a/test/octaspace/resources/hosting_test.rb b/test/octaspace/resources/hosting_test.rb index 3a2b52d..1d1c7b3 100644 --- a/test/octaspace/resources/hosting_test.rb +++ b/test/octaspace/resources/hosting_test.rb @@ -12,7 +12,7 @@ def setup def test_nodes stub_get("/hosting/nodes", fixture_path: "hosting_nodes.json") - node = @client.hosting.nodes.find { |n| n.number == "M95S-WZG8-7" } + node = @client.hosting.nodes.find { |n| n.number == "7KQ4-M2XD-4" } assert node.idle? assert node.verified? @@ -24,7 +24,7 @@ def test_nodes assert_equal 132_114_866_176, node.system.memory.size assert_equal "NVIDIA GeForce RTX 3090", node.system.gpus.first.model assert_equal "P8", node.system.gpus.first.pstate - assert_equal "Mukachevo", node.location.city + assert_equal "Frankfurt", node.location.city assert_equal Time.utc(2026, 11, 1, 18, 43, 45), node.online_time assert_nil node.activity end @@ -40,11 +40,11 @@ def test_nodes_repair_null_strings_in_gpus end def test_set_prices_sends_only_given_fields - stub_request(:patch, "#{BASE}/hosting/nodes/M95S-WZG8-7/prices") + stub_request(:patch, "#{BASE}/hosting/nodes/7KQ4-M2XD-4/prices") .with(body: '{"base":250000,"min_rent_minutes":15}') .to_return(status: 204, body: "") - assert_nil @client.hosting.set_prices("M95S-WZG8-7", base: 250_000, min_rent_minutes: 15, traffic: nil) + assert_nil @client.hosting.set_prices("7KQ4-M2XD-4", base: 250_000, min_rent_minutes: 15, traffic: nil) end def test_set_prices_validates_locally diff --git a/test/octaspace/resources/render_test.rb b/test/octaspace/resources/render_test.rb index 24d4525..2811cbf 100644 --- a/test/octaspace/resources/render_test.rb +++ b/test/octaspace/resources/render_test.rb @@ -16,7 +16,7 @@ def test_list_returns_render_machines assert(machines.all?(OctaSpace::Types::RenderMachine)) m = machines.first - assert_equal "7R2S-S05E-S", m.node_number + assert_equal "3HW9-P6TR-8", m.node_number assert_equal 100_000, m.base_price assert_equal 5101, m.blender_score refute_respond_to m, :storage_price @@ -24,10 +24,10 @@ def test_list_returns_render_machines def test_start_sends_only_node_number stub_request(:post, "#{BASE}/services/render") - .with(body: '{"node_number":"7R2S-S05E-S"}') + .with(body: '{"node_number":"3HW9-P6TR-8"}') .to_return(status: 201, body: '{"uuid":"r-1"}') - assert_equal "r-1", @client.render.start(node_number: "7R2S-S05E-S") + assert_equal "r-1", @client.render.start(node_number: "3HW9-P6TR-8") end def test_start_503_is_service_unavailable @@ -39,11 +39,11 @@ def test_start_503_is_service_unavailable 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"}]') + .with(body: '[{"id":0,"node_number":"3HW9-P6TR-8"},{"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"}]) + results = @client.render.start_batch([{node_number: "3HW9-P6TR-8"}, {node_number: "ZZZZ-ZZZZ-Z"}]) assert(results.all?(&:failed?)) assert_equal "Service is temporarily unavailable", results.first.reason end diff --git a/test/octaspace/resources/sessions_test.rb b/test/octaspace/resources/sessions_test.rb index 84a131f..da532d4 100644 --- a/test/octaspace/resources/sessions_test.rb +++ b/test/octaspace/resources/sessions_test.rb @@ -41,7 +41,7 @@ def test_get_parses_a_ready_compute_session assert s.compute? assert s.ready? - assert_equal "M95S-WZG8-7", s.node_number + assert_equal "7KQ4-M2XD-4", 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 diff --git a/test/octaspace/resources/stats_test.rb b/test/octaspace/resources/stats_test.rb index 6c214c7..45485d6 100644 --- a/test/octaspace/resources/stats_test.rb +++ b/test/octaspace/resources/stats_test.rb @@ -60,7 +60,7 @@ def test_gpu_locations stub_get("/stats/gpu/locations", fixture_path: "stats_gpu_locations.json") loc = @client.stats.gpu_locations.first - assert_equal "Mukachevo", loc.city + assert_equal "Frankfurt", loc.city assert_equal "Arc B580", loc.model assert_equal 2, loc.gpus end diff --git a/test/octaspace/transport/faraday_transport_test.rb b/test/octaspace/transport/faraday_transport_test.rb index 0202e39..ea530fc 100644 --- a/test/octaspace/transport/faraday_transport_test.rb +++ b/test/octaspace/transport/faraday_transport_test.rb @@ -33,10 +33,10 @@ def test_get_parses_json_body def test_post_sends_json_body stub_request(:post, "#{BASE}/services/compute") - .with(body: '{"node_number":"M95S-WZG8-7"}', headers: {"Content-Type" => "application/json"}) + .with(body: '{"node_number":"7KQ4-M2XD-4"}', headers: {"Content-Type" => "application/json"}) .to_return(status: 201, body: '{"uuid":"u"}', headers: JSON_HEADERS) - assert_equal "u", transport.post("/services/compute", body: {node_number: "M95S-WZG8-7"}).data["uuid"] + assert_equal "u", transport.post("/services/compute", body: {node_number: "7KQ4-M2XD-4"}).data["uuid"] end def test_empty_body_on_204_and_202 From 18107d7f15750aa0b5d38d99eafef36dbc6385e4 Mon Sep 17 00:00:00 2001 From: secretpray Date: Fri, 6 Nov 2026 12:08:21 +0200 Subject: [PATCH 2/4] chore: drop local tool files from .gitignore --- .gitignore | 2 -- 1 file changed, 2 deletions(-) diff --git a/.gitignore b/.gitignore index 0d622da..be9f938 100644 --- a/.gitignore +++ b/.gitignore @@ -6,12 +6,10 @@ /pkg/ /spec/reports/ /tmp/ -.tmp/ *.gem *.rbc .env .env.* Gemfile.lock gemfiles/*.lock -.claude/ .vscode/ From f8ab73fcb23462cfd02a442c5c606bd69caa6d09 Mon Sep 17 00:00:00 2001 From: secretpray Date: Fri, 6 Nov 2026 12:08:32 +0200 Subject: [PATCH 3/4] chore: add LICENSE with the standard MIT text --- LICENSE | 21 +++++++++++++++++++++ MIT-LICENSE | 22 ---------------------- README.md | 4 ++-- octaspace.gemspec | 2 +- 4 files changed, 24 insertions(+), 25 deletions(-) create mode 100644 LICENSE delete mode 100644 MIT-LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7af06bf --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OctaSpace + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MIT-LICENSE b/MIT-LICENSE deleted file mode 100644 index ae4dc8e..0000000 --- a/MIT-LICENSE +++ /dev/null @@ -1,22 +0,0 @@ -Copyright (c) 2024-present OctaSpace - -MIT License - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be included -in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 931387c..40df8f8 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Gem Version](https://img.shields.io/gem/v/octaspace)](https://rubygems.org/gems/octaspace) [![CI](https://github.com/octaspace/ruby-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/octaspace/ruby-sdk/actions/workflows/ci.yml) -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](MIT-LICENSE) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) Official Ruby SDK for the [OctaSpace API](https://api-dev.octa.space/swagger). Requires Ruby 3.3 or newer. @@ -192,4 +192,4 @@ closed when the process exits. ## License -[MIT](MIT-LICENSE) +[MIT](LICENSE) diff --git a/octaspace.gemspec b/octaspace.gemspec index 47b0d0c..c8df018 100644 --- a/octaspace.gemspec +++ b/octaspace.gemspec @@ -23,7 +23,7 @@ Gem::Specification.new do |spec| "rubygems_mfa_required" => "true" } - spec.files = Dir["lib/**/*.rb", "MIT-LICENSE", "README.md", "CHANGELOG.md"] + spec.files = Dir["lib/**/*.rb", "LICENSE", "README.md", "CHANGELOG.md"] spec.require_paths = ["lib"] spec.add_dependency "faraday", ">= 2.14.3", "< 3" From a55cc3adf89c48cbb5a3e8cebbde9a1ffbccbef7 Mon Sep 17 00:00:00 2001 From: secretpray Date: Fri, 6 Nov 2026 12:14:42 +0200 Subject: [PATCH 4/4] docs: user guides, dashboard terms for API keys, clean API reference - README: installation, quick start, creating an API key under Account -> Credentials -> API Keys, calls without a key, errors, API overview. - Guides under docs/: client configuration, resources, sessions, errors and retries. - CHANGELOG: unreleased changes under [Unreleased], released entries limited to user-facing changes, compare links for every version. - YARD comments and the missing-key error message use the dashboard's names. - Gem summary and description in user terms; changelog and documentation links per version. --- CHANGELOG.md | 126 +++++---- README.md | 261 ++++++++----------- docs/errors-and-reliability.md | 79 ++++++ docs/getting-started.md | 138 ++++++++++ docs/resources.md | 219 ++++++++++++++++ docs/sessions.md | 128 +++++++++ lib/octaspace.rb | 13 +- lib/octaspace/client.rb | 13 +- lib/octaspace/configuration.rb | 22 +- lib/octaspace/errors.rb | 7 +- lib/octaspace/json_null.rb | 9 +- lib/octaspace/middleware/url_rotator.rb | 6 +- lib/octaspace/model.rb | 7 +- lib/octaspace/railtie.rb | 3 +- lib/octaspace/resources/accounts.rb | 7 +- lib/octaspace/resources/base.rb | 6 +- lib/octaspace/resources/compute.rb | 17 +- lib/octaspace/resources/hosting.rb | 10 +- lib/octaspace/resources/network.rb | 2 +- lib/octaspace/resources/render.rb | 12 +- lib/octaspace/resources/sessions.rb | 4 +- lib/octaspace/resources/stats.rb | 6 +- lib/octaspace/response.rb | 3 +- lib/octaspace/transport/faraday_transport.rb | 4 +- lib/octaspace/types/account.rb | 2 +- lib/octaspace/types/machines.rb | 6 +- lib/octaspace/types/session.rb | 6 +- octaspace.gemspec | 9 +- 28 files changed, 827 insertions(+), 298 deletions(-) create mode 100644 docs/errors-and-reliability.md create mode 100644 docs/getting-started.md create mode 100644 docs/resources.md create mode 100644 docs/sessions.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bccdfc..2ee5980 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,85 +1,103 @@ # Changelog -All notable changes to this project will be documented in this file. +All notable changes to this project are documented in this file. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.3.0] - Unreleased +## [Unreleased] -Supports the current OctaSpace API, which differs from the one 0.2.x was written for in -authentication, node identifiers, money units and most payloads. Not backward compatible. +The SDK follows the current OctaSpace API. Most resources changed; see the +[resources](docs/resources.md) and [sessions](docs/sessions.md) guides. ### Added -- `client.compute`: `list`, `start`, `start_batch`, `restart`. -- `client.render`: `list`, `start`, `start_batch`. -- `client.sessions`: `list`, `get`, `logs(tail:)`, `stop`, `wait_ready`, `wait_stopped`. -- `client.hosting`: `nodes`, `set_prices`. -- `client.stats`: `gpu_current`, `gpu_history`, `gpu_locations`. -- Typed read-only response models (`OctaSpace::Types::*`). -- `OctaSpace::Money` for integer micro-USD amounts. -- `ConflictError` (409), `SessionStoppedError`, `WaitTimeoutError`; `retry_after`, `rate_limit` and - `body` on API errors; `client.rate_limit`. +- `client.compute`: `list`, `start`, `start_batch` and `restart`. +- `client.render`: `list`, `start` and `start_batch`. +- `client.sessions`: `get`, `logs`, `stop`, `wait_ready` and `wait_stopped`. +- `client.hosting`: `nodes` and `set_prices`. +- `client.stats`: `gpu_current`, `gpu_history` and `gpu_locations`. +- Read-only response models under `OctaSpace::Types`, with `[]`, `to_h` and + pattern matching. +- `OctaSpace::Money` for amounts in micro-USD. +- `ConflictError` (HTTP 409), `SessionStoppedError` and `WaitTimeoutError`. +- `retry_after`, `rate_limit` and `body` on `ApiError`, and `client.rate_limit`. +- The `max_retry_after` and `max_retry_interval` options. +- `OctaSpace::DEFAULT_BASE_URL` constant. +- Guides under `docs/`. ### Changed -- Authentication is `Authorization: Bearer ` with `octa_ak_…` API keys. -- Default base URL is `https://api-dev.octa.space`. -- Nodes are identified by `node_number` (e.g. `7KQ4-M2XD-4`) instead of an integer id. -- Prices, charges and the balance are integer micro-USD. -- Resource methods return models, session uuids or `nil` instead of `OctaSpace::Response`. -- 400 raises `ValidationError`; error messages come from the API. -- Only GET requests and `sessions.stop` are retried; starts and restarts never are. -- Logs never include request headers or bodies; `on_request` no longer receives headers. -- Requires Ruby 3.3+ and faraday 2.14.3+; `faraday-retry` is no longer a dependency. +- **Breaking:** the API key is sent as `Authorization: Bearer `. A key + that already has the `Bearer ` prefix is accepted. +- **Breaking:** the default base URL is `https://api-dev.octa.space` + (`DEFAULT_BASE_URL`). +- **Breaking:** nodes are identified by their node number, for example + `7KQ4-M2XD-4`. +- **Breaking:** prices, charges and the balance are integer micro-USD. +- **Breaking:** resource methods return models, session UUIDs or `nil` instead + of `OctaSpace::Response`. +- **Breaking:** `sessions.list` takes no parameters and returns running + sessions only. +- **Breaking:** HTTP 400 raises `ValidationError`, and the error message is the + API's message. +- **Breaking:** only GET requests and `sessions.stop` are retried; starting and + restarting sessions are never retried. A `Retry-After` longer than 60 seconds + raises the error at once. +- **Breaking:** a method that needs an API key raises `AuthenticationError` + before sending a request when none is configured. +- **Breaking:** `on_request` no longer receives the request headers. The log + never contains headers or bodies. +- **Breaking:** requires Ruby 3.3 or later and Faraday 2.14.3 or later; + `faraday-retry` is no longer a dependency. ### Removed -- `nodes`, `apps`, `idle_jobs`, `services` (`mr`, `vpn`, `render`, `session`), - `accounts.profile` and `accounts.generate_wallet`: the API no longer offers them. -- `ProvisionRejectedError`, `PayloadHelpers`, the `persistent` configuration alias. +- **Breaking:** `client.nodes`, `client.apps`, `client.idle_jobs`, + `client.services`, `accounts.profile` and `accounts.generate_wallet`, with + their types. Hosted nodes and their prices are in `client.hosting`, sessions + in `client.sessions`. +- **Breaking:** `ProvisionRejectedError`, `PayloadHelpers`, + `Transport::MockTransport`, the `OctaSpace::Engine` playground and the + `persistent` configuration alias of `keep_alive`. ## [0.2.0] - 2026-04-16 ### Added -- Support for `client.services.session(uuid).logs(recent: true)` for finished-session log retrieval. -- `OctaSpace::ProvisionRejectedError` for MR create requests that are transport-successful but rejected by the API payload contract. -- `OctaSpace::PayloadHelpers` with targeted helpers for stringified app port lists and marketplace bandwidth normalization. -- Diagnostics preset and manual runner support for recent session logs. -- Live-shaped fixtures for recent sessions and render marketplace responses. +- `client.services.session(uuid).logs(recent: true)` for the logs of a finished + session. +- `OctaSpace::ProvisionRejectedError` for machine rental requests that the API + accepts but rejects in the response body. +- `OctaSpace::PayloadHelpers` for app port lists sent as strings and for + marketplace bandwidth values. ### Changed -- `services.mr.list`, `services.render.list`, and `services.vpn.list` are now documented and tested as marketplace catalog endpoints rather than session collections. -- `services.mr.create` now forwards optional `organization_id` and `project_id`. -- Playground Services now presents marketplace-oriented summaries with normalized bandwidth display. -- Playground and README copy now reflect live API quirks for app ports, recent sessions, and finished-session logs. -- Publishing guidance now uses an explicit manual release flow, and the local `rake release` helper is disabled to avoid accidental RubyGems publication. +- `services.mr.list`, `services.render.list` and `services.vpn.list` return + the marketplace catalog, not sessions. +- `services.mr.create` passes the optional `organization_id` and `project_id`. ### Fixed -- Corrected the Ruby public API gap for recent finished-session logs. -- Corrected MR create handling for `HTTP 200` batch-style rejection responses. -- Corrected fixtures and tests that modeled `/services/mr` and `/services/vpn` as session lists. -- Corrected logs fixtures and tests to match the live `{system, container}` payload shape. -- Corrected the playground Apps page so port counts work when the live API returns stringified JSON arrays. -- Corrected test coverage for `sessions?recent=true` by using live-shaped string telemetry fields. +- `services.mr.create` raises `ProvisionRejectedError` for a rejection returned + with HTTP 200. ## [0.1.0] - 2026-04-12 +First release. + ### Added -- Initial release of the official OctaSpace Ruby SDK. -- Support for core resources: Accounts, Nodes, Sessions, Apps, Network, and Idle Jobs. -- Resource-oriented client: `client.nodes.list`, `client.services.session(uuid).stop`. -- Persistent HTTP connections (keep-alive mode) via `ConnectionPool`. -- Automatic URL rotation and failover for high availability. -- Retry with exponential backoff and jitter for transient errors. -- Typed error hierarchy based on HTTP status codes. -- Rails integration with automatic client sharing and graceful shutdown. -- Interactive Playground app for manual testing and diagnostics. - -[0.2.0]: https://github.com/octaspace/ruby-sdk/compare/v0.1.0...HEAD +- `OctaSpace::Client` and `OctaSpace.configure`, with accounts, nodes, + sessions, apps, network, idle jobs and services resources. +- Persistent HTTP connections with a connection pool (`keep_alive`). +- Failover between several base URLs. +- Retries with exponential backoff and jitter for transient errors. +- Typed errors mapped from HTTP status codes. +- Rails integration: a shared client that is shut down when the process exits. +- A playground Rails engine for trying the API. + +[Unreleased]: https://github.com/octaspace/ruby-sdk/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/octaspace/ruby-sdk/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/octaspace/ruby-sdk/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 40df8f8..2b8a586 100644 --- a/README.md +++ b/README.md @@ -1,194 +1,153 @@ -# octaspace +# OctaSpace Ruby SDK [![Gem Version](https://img.shields.io/gem/v/octaspace)](https://rubygems.org/gems/octaspace) [![CI](https://github.com/octaspace/ruby-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/octaspace/ruby-sdk/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -Official Ruby SDK for the [OctaSpace API](https://api-dev.octa.space/swagger). Requires Ruby 3.3 or newer. +The official Ruby library for the [OctaSpace](https://octa.space) API. Rent +compute machines and render nodes, manage sessions and the nodes you host, and +read GPU market statistics from Ruby code. + +**Contents:** [Installation](#installation) · +[Quick start](#quick-start) · [Authentication](#authentication) · +[Handling errors](#handling-errors) · [API overview](#api-overview) · +[Documentation](#documentation) · [Versioning](#versioning) ## Installation -```ruby -# Gemfile -gem "octaspace" +```bash +bundle add octaspace ``` -## API key - -Create a key in Cube under **Profile → Credentials**. Keys start with `octa_ak_` and are sent as -`Authorization: Bearer `. - -The key is optional for public data: network and GPU statistics never use it, and machine listings -work without it (the anonymous listing may be up to an hour old; with a key it is at most 10 seconds -old). Methods that need a key raise `OctaSpace::AuthenticationError` before sending a request when -none is configured. +Requires Ruby 3.3 or later. The only runtime dependency is +[Faraday](https://github.com/lostisland/faraday). ## Quick start -```ruby -require "octaspace" +1. [Create an API key](#authentication) and export it: -client = OctaSpace::Client.new(api_key: ENV["OCTA_API_KEY"]) + ```bash + export OCTA_API_KEY="octa_ak_..." + ``` -client.accounts.balance.formatted # => "$12.500000" +2. Run this program: -machine = client.compute.list.min_by(&:base_price) -uuid = client.compute.start( - node_number: machine.node_number, - image: "ubuntu:24.04", - disk_size: 20, - http_ports: [8888], - envs: {"FOO" => "bar"}, - ssh: true -) + ```ruby + require "octaspace" -session = client.sessions.wait_ready(uuid, timeout: 600) do |s| - puts "#{s.status} #{s.progress} #{s.pull_percent}" -end -session.urls # => {"8888" => "https://…"} -session.ssh.endpoint # direct SSH, or the SSH proxy + client = OctaSpace::Client.new(api_key: ENV["OCTA_API_KEY"]) -client.sessions.logs(uuid, tail: 100).container -client.sessions.stop(uuid) -client.sessions.wait_stopped(uuid) -``` + begin + balance = client.accounts.balance + puts "Balance: #{balance.formatted}" # Balance: $12.500000 -## Resources - -| Method | Endpoint | Key | Returns | -|---|---|---|---| -| `network.info` | `GET /network` | no | `Types::Network` | -| `stats.gpu_current(verified:)` | `GET /stats/gpu/current` | no | `Types::GpuMarket` | -| `stats.gpu_history(gpu_name:, verified:, from:, to:, step:)` | `GET /stats/gpu/history` | no | `{model => Types::GpuHistory}` | -| `stats.gpu_locations` | `GET /stats/gpu/locations` | no | `[Types::GpuLocation]` | -| `accounts.balance` | `GET /accounts/balance` | yes | `Types::Balance` | -| `compute.list` | `GET /services/compute` | optional | `[Types::ComputeMachine]` | -| `compute.start(node_number:, image:, …)` | `POST /services/compute` | yes | session uuid | -| `compute.start_batch(items)` | `POST /services/compute` | yes | `[Types::BatchResult]` | -| `compute.restart(uuid, **params)` | `POST /services/compute/{uuid}/restart` | yes | session uuid | -| `render.list` | `GET /services/render` | optional | `[Types::RenderMachine]` | -| `render.start(node_number:)` | `POST /services/render` | yes | session uuid | -| `render.start_batch(items)` | `POST /services/render` | yes | `[Types::BatchResult]` | -| `sessions.list` | `GET /sessions` | yes | `[Types::Session]` | -| `sessions.get(uuid)` | `GET /services/{uuid}/info` | yes | `Types::Session` | -| `sessions.logs(uuid, tail:)` | `GET /services/{uuid}/logs` | yes | `Types::SessionLogs` | -| `sessions.stop(uuid)` | `POST /services/{uuid}/stop` | yes | session uuid | -| `sessions.wait_ready(uuid, timeout:, interval:)` | polls `sessions.get` | yes | `Types::Session` | -| `sessions.wait_stopped(uuid, timeout:, interval:)` | polls `sessions.get` | yes | `true` | -| `hosting.nodes` | `GET /hosting/nodes` | yes | `[Types::HostingNode]` | -| `hosting.set_prices(number, base:, storage:, traffic:, min_rent_minutes:)` | `PATCH /hosting/nodes/{number}/prices` | yes | `nil` | - -Responses are read-only models with readers for the documented fields. Fields without a reader are -still available as `model[:field]`, and `model.to_h` returns the parsed JSON. `sessions.list` and -`sessions.get` return a `Types::ComputeSession` or a `Types::RenderSession`, chosen by the `service` -field. - -## Units - -| What | Unit | Helper | -|---|---|---| -| Prices, charges, balance | integer micro-USD (1 = $0.000001) | `OctaSpace::Money` | -| `started_at` (sessions), `ts` (logs) | Unix milliseconds | `started_time`, `time` | -| `at` (network, stats) | Unix seconds | `time` | -| `online_at` (hosting) | ISO 8601 | `online_time` | -| `net_down_mbps`, `net_up_mbps` | Mbit/s (0 in listings: not measured) | | -| `memory`, `disk` in listings | free bytes | | -| `node_hw.disk` in sessions | rented GB | | + client.compute.list.each do |m| + puts "#{m.node_number} #{m.city} #{OctaSpace::Money.format(m.base_price)} per hour" + end + rescue OctaSpace::Error => e + abort e.message + end + ``` -```ruby -OctaSpace::Money.format(100) # => "$0.000100" -OctaSpace::Money.from_usd("0.25") # => 250000 -client.hosting.set_prices("7KQ4-M2XD-4", base: OctaSpace::Money.from_usd("0.25"), min_rent_minutes: 15) -``` +Create one client and reuse it: it is safe to share between threads. -## Starting several sessions +## Authentication -```ruby -results = client.compute.start_batch([ - {id: "a", node_number: "7KQ4-M2XD-4", image: "ubuntu:24.04"}, - {id: "b", node_number: "M2XD-7KQ4-0", image: "ubuntu:24.04"} -]) -results.each { |r| puts r.started? ? "#{r.id}: #{r.uuid}" : "#{r.id}: #{r.reason}" } -``` +The SDK authenticates with an **API key** of your OctaSpace account. You create +and delete API keys in the OctaSpace dashboard: -Each item succeeds or fails on its own and the failure is reported in its result. Items without an -`id` get their position in the array. A single `start` raises `OctaSpace::ValidationError` instead. +1. Sign in to the [OctaSpace dashboard](https://cube-dev.octa.space). +2. In the sidebar, open **Account → Credentials**. +3. In the **API Keys** section, click **New key**. +4. Enter a **Label** that tells you where the key is used, for example + `billing-service`, and click **Create key**. +5. Copy the key. It starts with `octa_ak_`. -## Restarting a compute session +> [!IMPORTANT] +> The key is shown in full only once, right after you create it. Copy it +> before you close the dialog. If you lose it, delete it and create a new one. + +Pass the key to `OctaSpace::Client.new`. The SDK sends it in the +`Authorization: Bearer ` header. ```ruby -client.compute.restart(uuid) # same parameters as before -client.compute.restart(uuid, disk_size: 20, envs: {"FOO" => "baz"}, http_ports: [8888], ssh: true) +client = OctaSpace::Client.new(api_key: ENV["OCTA_API_KEY"]) ``` -With parameters, a restart **replaces all of them**: every omitted parameter gets its default, not its -current value (`disk_size` becomes 1 GB; `envs`, ports and `startup_script` are dropped). Pass the -full set you want. A session can be restarted once per 5 minutes and 6 times per hour -(`OctaSpace::RateLimitError`, see `retry_after`); while it is still starting or restarting, the API -answers `OctaSpace::ConflictError`. +> [!WARNING] +> An API key acts on behalf of your account: it can start sessions that are +> charged to your balance. Keep it out of source code and version control. If +> a key leaks, delete it under **Account → Credentials → API Keys**; every +> program that uses it stops working at once. -## Sessions and SSH +The **API Keys** list shows each key's label, its first characters and when it +was last used. Use a separate key for each program or machine, so that you can +delete one without affecting the others. The number of keys per account is +limited; the counter next to **API Keys** shows how many you have. -Only running sessions are reported. Once a session stops, `get`, `logs` and `stop` raise -`OctaSpace::NotFoundError`. `stop` is asynchronous; `wait_stopped` waits until the session is gone. A -session stopped before its `min_rent_minutes` is charged for that long. +### Calls without an API key -`ssh.web` (web terminal) contains credentials and `fm` (file manager) needs no login, so treat both -as secrets; `inspect` hides them. `ssh.*.user` comes from the image: pass it to `ssh` as a separate -argument, after `--`: `ssh -p PORT -l USER -- HOST`. +These methods also work without an API key. Create the client without +`api_key:` to use them anonymously: -## Errors +| Method | Without a key | +| --- | --- | +| `network.info` | Same result | +| `stats.gpu_current`, `stats.gpu_history`, `stats.gpu_locations` | Same result | +| `compute.list`, `render.list` | Refreshed about once an hour instead of every 10 seconds | -``` -OctaSpace::Error -├── ConfigurationError -├── NetworkError -│ ├── ConnectionError -│ └── TimeoutError -│ └── WaitTimeoutError wait_ready / wait_stopped timed out -└── ApiError status, message, body, retry_after, rate_limit, request_id - ├── ValidationError 400, 422 - ├── AuthenticationError 401, or no API key configured - ├── PermissionError 403 - ├── NotFoundError 404 - │ └── SessionStoppedError the session stopped during wait_ready - ├── ConflictError 409 - ├── RateLimitError 429 - └── ServerError 5xx - ├── BadGatewayError 502 - ├── ServiceUnavailableError 503 - └── GatewayTimeoutError 504 -``` +Every other method requires an API key and raises +`OctaSpace::AuthenticationError` without one, before any request is sent. -GET requests and `sessions.stop` are retried on network errors and on 429/502/503/504, honouring -`Retry-After` up to 60 seconds; a longer wait is raised to the caller. Starts and restarts are never -retried. `client.rate_limit` returns the `RateLimit-*` values of the latest response. +## Handling errors -## Configuration +Every error the SDK raises is an `OctaSpace::Error`. Rescue the specific class +you can handle: ```ruby -OctaSpace.configure do |config| - config.api_key = ENV["OCTA_API_KEY"] +begin + uuid = client.compute.start(node_number: "7KQ4-M2XD-4", image: "ubuntu:24.04") +rescue OctaSpace::AuthenticationError + # the API key is missing, wrong or deleted +rescue OctaSpace::ValidationError => e + # the request was rejected; e.message says why +rescue OctaSpace::RateLimitError => e + # too many requests; wait e.retry_after seconds end - -client = OctaSpace.client # shared client -other = OctaSpace::Client.new(api_key: "octa_ak_…", read_timeout: 60) ``` -| Option | Default | Description | -|---|---|---| -| `api_key` | `nil` | API key | -| `base_url` | `https://api-dev.octa.space` | API endpoint | -| `base_urls` | `nil` | Several endpoints; GET requests fail over between them on network errors | -| `open_timeout` / `read_timeout` / `write_timeout` | `10` / `30` / `30` | Seconds | -| `max_retries` | `2` | Retries of GET requests and `sessions.stop` | -| `keep_alive` | `false` | Persistent connections; add `faraday-net_http_persistent` and `connection_pool` to your Gemfile | -| `pool_size` / `pool_timeout` / `idle_timeout` | `5` / `5` / `60` | Connection pool (keep-alive) | -| `on_request` / `on_response` | `nil` | Callables run before each request and after each response | -| `logger` / `log_level` | `nil` / `:info` | One line per request; headers and bodies are never logged | - -In a Rails app, put the `configure` block in an initializer. The shared client's connections are -closed when the process exits. +See [Errors, rate limits and retries](docs/errors-and-reliability.md). + +## API overview + +| Resource | Dashboard section | Operations | Guide | +| --- | --- | --- | --- | +| `client.compute` | Marketplace → Compute | List machines, start, batch start, restart | [Compute](docs/resources.md#compute) | +| `client.render` | Render → Launch Nodes | List render nodes, start, batch start | [Render](docs/resources.md#render) | +| `client.sessions` | Marketplace → Sessions, Render → Render Nodes | List, get, logs, stop, wait until ready or stopped | [Sessions](docs/sessions.md) | +| `client.hosting` | Hosting → Nodes | Your nodes, set prices | [Hosting](docs/resources.md#hosting) | +| `client.accounts` | Account → Billing | Balance | [Accounts](docs/resources.md#accounts) | +| `client.network` | — | Platform statistics | [Network](docs/resources.md#network) | +| `client.stats` | — | GPU market: current, history, locations | [Stats](docs/resources.md#stats) | + +Every class and method is documented on +[RubyDoc.info](https://rubydoc.info/gems/octaspace). + +## Documentation + +| Guide | Covers | +| --- | --- | +| [Client configuration](docs/getting-started.md) | Options and defaults, timeouts, logging, Rails, keep-alive connections | +| [Resources](docs/resources.md) | Compute, render, hosting, balance, network and GPU statistics | +| [Sessions](docs/sessions.md) | Waiting until ready, connecting, logs, stopping | +| [Errors, rate limits and retries](docs/errors-and-reliability.md) | Error classes, limits, automatic retries | +| [Changelog](CHANGELOG.md) | Changes in each release | + +## Versioning + +The library follows [Semantic Versioning](https://semver.org). Until version +1.0, a minor release can contain breaking changes; the +[changelog](CHANGELOG.md) marks them as **Breaking**. ## License diff --git a/docs/errors-and-reliability.md b/docs/errors-and-reliability.md new file mode 100644 index 0000000..959e93d --- /dev/null +++ b/docs/errors-and-reliability.md @@ -0,0 +1,79 @@ +# Errors, rate limits and retries + +[← Back to README](../README.md) + +**On this page:** [Error classes](#error-classes) · +[Rate limits](#rate-limits) · [Retries](#retries) + +## Error classes + +An error response from the API is raised as a subclass of +`OctaSpace::ApiError`, which holds the HTTP `status`, the API's message, the +`request_id`, the parsed `body`, `retry_after` and the `rate_limit` state. + +| Class | Status | Typical cause | +| --- | --- | --- | +| `ValidationError` | 400, 422 | A parameter is missing or invalid, or the request cannot be met (for example an insufficient balance) | +| `AuthenticationError` | 401 | The API key is missing, wrong or deleted | +| `PermissionError` | 403 | Access denied | +| `NotFoundError` | 404 | Unknown or stopped session, unknown node | +| `ConflictError` | 409 | The session is already starting or restarting | +| `RateLimitError` | 429 | Too many requests, or the session was restarted too recently | +| `ServerError` | 5xx | A temporary problem on the API side | +| `ServiceUnavailableError` | 503 | The service is temporarily unavailable; a subclass of `ServerError` | + +All classes are in the `OctaSpace` namespace. When the API cannot be reached at +all, the error is an `OctaSpace::NetworkError`: `ConnectionError` (DNS, +connection or TLS failure) or `TimeoutError`. Every error the SDK raises is an +`OctaSpace::Error`. + +```ruby +begin + client.compute.start(node_number: "7KQ4-M2XD-4", image: "ubuntu:24.04") +rescue OctaSpace::ApiError => e + warn "status #{e.status}: #{e.message} (request #{e.request_id})" +end +``` + +> [!TIP] +> Include the request ID when you contact support about a failed call. + +## Rate limits + +| Requests | Limit per minute | +| --- | --- | +| Without an API key | 60 per IP address | +| With an API key | 300 per account | +| Starting and restarting sessions | 10 per account | + +`client.rate_limit` returns the `limit`, the `remaining` requests and the +seconds until the window resets (`reset`), as of the latest response. A failed +call carries the same in `ApiError#rate_limit`. + +On a `RateLimitError`, wait `retry_after` seconds before you try again: + +```ruby +attempts = 0 +begin + client.compute.restart(uuid) +rescue OctaSpace::RateLimitError => e + raise if (attempts += 1) > 2 + sleep(e.retry_after || 60) + retry +end +``` + +## Retries + +GET requests and `sessions.stop` are retried automatically on connection +errors, timeouts and the statuses 429, 502, 503 and 504, with exponential +backoff and jitter. They honour `Retry-After` up to 60 seconds; when the API +asks to wait longer, the `RateLimitError` is raised at once. + +> [!IMPORTANT] +> Starting and restarting sessions and setting prices are never retried, so +> that they cannot run twice. Decide yourself whether to repeat them after an +> error. + +Set the number of retries with `max_retries` and the longest wait with +`max_retry_after`; see [Client configuration](getting-started.md#options). diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..8d9bbcd --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,138 @@ +# Client configuration + +[← Back to README](../README.md) + +**On this page:** [Creating a client](#creating-a-client) · +[Options](#options) · [Timeouts](#timeouts) · [Logging and hooks](#logging-and-hooks) · +[Rails](#rails) · [Keep-alive connections](#keep-alive-connections) · +[Several hosts](#several-hosts) + +## Creating a client + +`OctaSpace::Client.new` takes your [API key](../README.md#authentication) and +optional settings. Create one client and reuse it: it is safe to share between +threads. + +```ruby +client = OctaSpace::Client.new( + api_key: ENV["OCTA_API_KEY"], + read_timeout: 15, + max_retries: 3, + user_agent: "my-service/1.0", + logger: Logger.new($stdout) +) +``` + +To set options once for the whole process, use `OctaSpace.configure`. +`OctaSpace.client` returns a client built from that configuration and reuses +it on every call: + +```ruby +OctaSpace.configure do |config| + config.api_key = ENV["OCTA_API_KEY"] + config.read_timeout = 15 +end + +OctaSpace.client.accounts.balance +``` + +Options passed to `Client.new` override the global configuration for that +client only. + +## Options + +| Option | Default | Effect | +| --- | --- | --- | +| `api_key` | `nil` | Your API key; without it only the [calls without a key](../README.md#calls-without-an-api-key) work | +| `open_timeout` | `10` | Seconds to wait for a connection | +| `read_timeout` | `30` | Seconds to wait for a response | +| `write_timeout` | `30` | Seconds to wait while sending a request | +| `max_retries` | `2` | Retries of failed requests that are safe to repeat; `0` turns them off. See [Retries](errors-and-reliability.md#retries) | +| `retry_interval` / `backoff_factor` / `max_retry_interval` | `0.5` / `2.0` / `10.0` | Backoff between retries, in seconds | +| `max_retry_after` | `60` | Longest `Retry-After` in seconds the SDK waits for; a longer wait raises the error at once | +| `user_agent` | `octaspace-ruby/ Ruby/` | Value of the `User-Agent` header | +| `logger` / `log_level` | `nil` / `:info` | Logs one line per request, see [below](#logging-and-hooks) | +| `on_request` / `on_response` | `nil` | Callables run before each request and after each response | +| `keep_alive` | `false` | Persistent connections, see [below](#keep-alive-connections) | +| `pool_size` / `pool_timeout` / `idle_timeout` | `5` / `5` / `60` | Connection pool of `keep_alive` | +| `ssl_verify` | `true` | Verify the server's TLS certificate | +| `base_url` | `OctaSpace::DEFAULT_BASE_URL` | Another API host | +| `base_urls` | `nil` | Several API hosts, see [below](#several-hosts) | + +## Timeouts + +The timeouts apply to each attempt of a request. A request that is retried can +take longer in total; set `max_retries: 0` if you need a hard limit. + +A request that takes too long raises `OctaSpace::TimeoutError`. + +## Logging and hooks + +Pass any `Logger`. The SDK logs the method, URL, status and duration of each +request at `log_level`: + +```text +[OctaSpace] GET https://…/accounts/balance -> 200 (84 ms) +``` + +> [!NOTE] +> The API key, other headers and request and response bodies are never logged. + +`on_request` receives a Hash with `:method`, `:url`, `:path`, `:params` and +`:attempt`; `on_response` receives the `OctaSpace::Response`. Use them for +metrics or tracing: + +```ruby +client = OctaSpace::Client.new( + api_key: ENV["OCTA_API_KEY"], + on_request: ->(req) { puts "-> #{req[:method].upcase} #{req[:path]} (attempt #{req[:attempt]})" }, + on_response: ->(resp) { puts "<- #{resp.status} #{resp.request_id}" } +) +``` + +## Rails + +Put the configuration in an initializer and call `OctaSpace.client` where you +need it: + +```ruby +# config/initializers/octaspace.rb +OctaSpace.configure do |config| + config.api_key = Rails.application.credentials.dig(:octaspace, :api_key) + config.logger = Rails.logger +end +``` + +The SDK closes the shared client's connections when the Rails process exits. + +## Keep-alive connections + +With `keep_alive: true` the client keeps connections open and reuses them from +a pool, which saves a TLS handshake per request. Add these gems to your +Gemfile: + +```ruby +gem "faraday-net_http_persistent" +gem "connection_pool" +``` + +```ruby +client = OctaSpace::Client.new( + api_key: ENV["OCTA_API_KEY"], + keep_alive: true, + pool_size: ENV.fetch("RAILS_MAX_THREADS", 5).to_i +) +``` + +Without those gems, `keep_alive: true` raises `OctaSpace::ConfigurationError`. +Call `client.shutdown` to close the connections of a client you no longer use. + +## Several hosts + +`base_urls` takes a list of API hosts, for example the default one and a proxy +of your own. Requests are spread over the hosts in turn. A GET request moves to +the next host only when the current one cannot be reached (DNS, connection or +TLS failure, timeout); an HTTP error response, including 5xx, is raised as it +is. A request that changes something (starting, restarting or stopping a +session, setting prices) is sent to one host only, so that it cannot run +twice. diff --git a/docs/resources.md b/docs/resources.md new file mode 100644 index 0000000..ab4ee00 --- /dev/null +++ b/docs/resources.md @@ -0,0 +1,219 @@ +# Resources + +[← Back to README](../README.md) + +**On this page:** [Conventions](#conventions) · [Compute](#compute) · +[Render](#render) · [Hosting](#hosting) · [Accounts](#accounts) · +[Network](#network) · [Stats](#stats) + +Sessions have [their own guide](sessions.md). + +## Conventions + +| Concept | In the SDK | +| --- | --- | +| Money | An integer number of micro-USD, millionths of a US dollar. `OctaSpace::Money.format(250_000)` returns `"$0.250000"`, `Money.to_usd` an exact `Rational`, `Money.from_usd("0.25")` the integer `250000`. | +| Node | Identified by its node number, for example `7KQ4-M2XD-4`. Case, dashes and spaces are ignored. | +| Network speed | Whole Mbit/s in `net_down_mbps` / `net_up_mbps`; `0` if not measured. | +| Time | `started_at` of sessions and `ts` of log entries are Unix milliseconds; `at` of statistics is Unix seconds. Each model has a method that returns a `Time` (`started_time`, `time`). | +| Results | Read-only models with a reader per field. `model[:field]` reads any field, including one without a reader; `model.to_h` returns the object as the API sent it. Models support pattern matching: `in {node_number:, base_price:}`. | + +## Compute + +Rent a machine and run your own container on it. + +### List machines + +```ruby +client.compute.list.each do |m| + puts "#{m.node_number} #{m.city} #{m.cpu_cores} #{OctaSpace::Money.format(m.base_price)}" +end +``` + +A `Types::ComputeMachine` has its location, CPU, memory and disk (free bytes), +GPUs, network speed, reliability and prices: + +| Field | Meaning | +| --- | --- | +| `base_price` | Per hour of rent | +| `storage_price` | Per GB of disk per hour | +| `traffic_price` | Per GB of traffic | +| `min_rent_minutes` | Minimum rent, see below; `0` if none | + +> [!NOTE] +> **Minimum rent.** If the node owner set a minimum rent, a session stopped +> sooner is charged as if it ran that long. It is not charged if the node +> fails. + +The listing also works without an API key, but is then refreshed about once an +hour; with a key it is at most 10 seconds old. + +### Start a session + +```ruby +uuid = client.compute.start( + node_number: "7KQ4-M2XD-4", + image: "ubuntu:24.04", + disk_size: 20, # GB + http_ports: [8888], + envs: {"APP_ENV" => "production"}, + ssh: true +) +``` + +`node_number` and `image` are required. Parameters you leave out are not sent +and take their defaults; all parameters are listed in the +[`Compute#start` reference](https://rubydoc.info/gems/octaspace/OctaSpace/Resources/Compute#start-instance_method). +Pass `ssh: false` or `fm: false` to turn off SSH or the file manager that an +application enables. An unknown parameter raises `ArgumentError`. + +`start` returns the session UUID as soon as the session is created, while its +container is still being set up. Wait for it with +[`sessions.wait_ready`](sessions.md#wait-until-ready). A rejected request, for +example for an insufficient balance or a node that is not available, raises +`OctaSpace::ValidationError` with the reason. + +### Start several sessions + +```ruby +results = client.compute.start_batch([ + {id: "a", node_number: "7KQ4-M2XD-4", image: "ubuntu:24.04"}, + {id: "b", node_number: "3HW9-P6TR-8", image: "ubuntu:24.04"} +]) +results.each do |r| + puts r.started? ? "#{r.id} started #{r.uuid}" : "#{r.id} failed: #{r.reason}" +end +``` + +Each item is checked on its own: one that fails does not stop the others. +Items without an `:id` get their position in the array. + +### Restart a session + +```ruby +client.compute.restart(uuid) # keep the parameters +client.compute.restart(uuid, disk_size: 20, http_ports: [8888], ssh: true) +``` + +`restart` recreates the container on the same node; the session keeps running, +with its UUID and prices. Without parameters the session restarts with the +parameters it was started with. + +> [!WARNING] +> Parameters you pass **replace** the session's parameters as a whole; they +> are not merged. Every parameter you leave out takes its default (`disk_size` +> becomes 1 GB, `envs`, ports and `startup_script` are dropped), so include +> everything the session needs. If the new parameters cannot be set up, the +> session stops. + +| Error | When | +| --- | --- | +| `OctaSpace::ConflictError` | The session is already starting or restarting | +| `OctaSpace::RateLimitError` | The session was restarted too recently; wait `retry_after` seconds. A session can be restarted once in 5 minutes and 6 times an hour | + +## Render + +Rent a render node. + +```ruby +nodes = client.render.list + +uuid = client.render.start(node_number: "7KQ4-M2XD-4") + +results = client.render.start_batch([ + {id: "a", node_number: "7KQ4-M2XD-4"}, + {id: "b", node_number: "3HW9-P6TR-8"} +]) +``` + +A `Types::RenderMachine` is a node with an NVIDIA GPU, its `blender_score` and +`base_price` per hour. The node renders for your account until you stop the +session. + +## Hosting + +The nodes you host and their prices. + +```ruby +client.hosting.nodes.each do |n| + puts "#{n.number} #{n.status} #{OctaSpace::Money.format(n.prices.base)} #{n.uptime_percent}" +end +``` + +`nodes` lists your nodes that are online, with their status (`"idle"`, +`"rented"`, …), the services they are offered for (`offered?("compute")`), +prices, hardware, location and uptime. + +### Set prices + +```ruby +client.hosting.set_prices("7KQ4-M2XD-4", + base: OctaSpace::Money.from_usd("0.25"), # $0.25 per hour + min_rent_minutes: 15) +``` + +Only the prices you pass change. Amounts are integer micro-USD. + +| Field | Meaning | Range | +| --- | --- | --- | +| `base` | Charged for every hour the node is rented | $0.0001 – $1000 | +| `storage` | Charged per hour for each GB of disk the renter allocates | $0 – $1000 | +| `traffic` | Per GB of traffic | $0.0001 – $1000 | +| `min_rent_minutes` | Minimum rent of a compute session | 0 – 60 minutes; `0` turns it off | + +A value out of range raises `OctaSpace::ValidationError`, an unknown node +`OctaSpace::NotFoundError`. New prices appear in the listings within seconds. + +## Accounts + +```ruby +balance = client.accounts.balance +balance.formatted # => "$12.500000" +balance.balance # => 12500000 (micro-USD) +``` + +The balance already includes what running sessions have used and becomes +negative when they use more than the account has (`negative?`). A recent charge +or deposit can take a moment to appear. + +## Network + +Platform statistics, updated every few minutes. No API key is needed. + +```ruby +info = client.network.info +puts info.nodes.online, info.sessions.running, info.time +``` + +`Types::Network` counts nodes, capacity, sessions, users, rendered frames and +VPN relays. + +## Stats + +GPU market statistics. No API key is needed. + +```ruby +market = client.stats.gpu_current(verified: "yes") +market.gpus.each do |g| + puts "#{g.model} #{g.available} #{g.rented} #{g.available_price.p50}" +end + +history = client.stats.gpu_history( + gpu_name: "NVIDIA GeForce RTX 4090", + from: Time.now - 7 * 86_400, + step: 6 * 3600 +) +history.fetch("NVIDIA GeForce RTX 4090").points.each do |p| + puts "#{p.time} #{p.rented} #{p.rented_price.p50}" +end + +locations = client.stats.gpu_locations +``` + +| Method | Returns | +| --- | --- | +| `gpu_current` | Per GPU model: the number of GPUs (not nodes), how many are rented, performance, and price percentiles in micro-USD per GPU-hour. A percentile is `nil` when there is no price. | +| `gpu_history` | The same over time, by model name. `gpu_name` takes one name or an array; `from` and `to` take a `Time` or Unix seconds and default to the last day. `step` is in seconds, at least 300 (default 3600); a range has at most 2000 steps. | +| `gpu_locations` | The number of GPUs by city and model | + +`verified:` takes `"yes"` (verified nodes only), `"no"` or `"all"` (default). diff --git a/docs/sessions.md b/docs/sessions.md new file mode 100644 index 0000000..ca0b9de --- /dev/null +++ b/docs/sessions.md @@ -0,0 +1,128 @@ +# Sessions + +[← Back to README](../README.md) + +**On this page:** [Lifecycle](#lifecycle) · [List and get](#list-and-get) · +[Wait until ready](#wait-until-ready) · [Connect](#connect) · [Logs](#logs) · +[Stop](#stop) · [Session fields](#session-fields) + +`client.sessions` works with your running sessions, both compute and render. +`list` and `get` return a `Types::ComputeSession` or a `Types::RenderSession`; +`compute?` and `render?` tell them apart. + +## Lifecycle + +```text +compute.start / render.start ──► starting ──► ready ──► sessions.stop + ▲ │ + └─ restart ─┘ +``` + +| `status` | Meaning | +| --- | --- | +| `"starting"` (`starting?`) | The container is being set up; see `progress` and `pull_percent` | +| `"ready"` (`ready?`) | The session is running and can be used | + +A stopped session is no longer returned by the API. + +## List and get + +```ruby +sessions = client.sessions.list # running sessions, oldest first + +begin + session = client.sessions.get(uuid) +rescue OctaSpace::NotFoundError + # the session has stopped, or the UUID is unknown +end +``` + +## Wait until ready + +`wait_ready` polls a session until it is ready and returns it. The block, if +given, receives every state it polls: + +```ruby +begin + session = client.sessions.wait_ready(uuid, timeout: 600) do |s| + puts "#{s.status} #{s.progress} #{s.pull_percent}" + end +rescue OctaSpace::SessionStoppedError + # the session stopped while starting, for example because SSH could not be set up +rescue OctaSpace::WaitTimeoutError + # still not ready after 10 minutes +end +``` + +It checks every 3 seconds; change this with `interval:`. Without `timeout:` it +waits until the session is ready or stops. + +## Connect + +When a compute session is ready, these fields tell you how to reach it: + +| Field | Contains | +| --- | --- | +| `ssh.direct`, `ssh.proxy` | SSH endpoints: `host`, `port` and the `user` to log in as. `ssh.endpoint` returns the direct one if there is one, otherwise the proxy | +| `ssh.web` | URL of the web terminal | +| `fm` | URL of the web file browser | +| `ports` | Each exposed container port mapped to its public port on the node | +| `urls` | Each HTTP port mapped to its public URL | + +The SSH fields are empty unless SSH was turned on when the session started, +and `fm` is empty unless the file browser was. + +```ruby +ep = session.ssh.endpoint +system("ssh", "-p", ep.port.to_s, "-l", ep.user, "--", ep.host) +``` + +> [!WARNING] +> Anyone with the `ssh.web` or `fm` URL can use the session without logging +> in: keep them out of logs. `inspect` shows them as `[REDACTED]`. The SSH +> `user` comes from the container image; pass it as a separate argument, as +> above, never as part of a shell string. + +## Logs + +```ruby +logs = client.sessions.logs(uuid, tail: 100) +print logs.container if logs.container +logs.system.each { |e| puts "#{e.time} #{e.msg}" } +``` + +| Field | Contains | +| --- | --- | +| `container` | The latest container output; `tail:` sets the number of lines, 1 – 1000 (200 by default). `nil` when the node cannot be reached, which differs from an empty log. | +| `container_truncated?` | `true` if the output was cut to its last 1 MiB | +| `system` | Platform events of the session, oldest first | + +Logs are available for running compute sessions. + +## Stop + +```ruby +client.sessions.stop(uuid) +client.sessions.wait_stopped(uuid, timeout: 60) +``` + +Stopping takes a few seconds, during which `get` can still return the session; +after that it raises `OctaSpace::NotFoundError`. `wait_stopped` waits for that +and returns `true`. Stopping a session twice is safe. + +> [!NOTE] +> A session stopped before its minimum rent (`min_rent_minutes`) is charged as +> if it ran that long. + +## Session fields + +| Field | Meaning | +| --- | --- | +| `base_price`, `storage_price`, `traffic_price` | Prices in micro-USD, fixed when the session starts | +| `charge_amount` | What the session has cost so far, micro-USD | +| `duration` | Seconds since the start | +| `started_at` | Unix milliseconds; `started_time` returns a `Time` | +| `rx`, `tx` | Bytes received and sent | +| `node_hw` | The node: CPU, memory, GPUs, the rented disk in GB, network speed | + +While a session is starting, its prices are `nil` and `node_hw` is `nil`. diff --git a/lib/octaspace.rb b/lib/octaspace.rb index 1d084c2..00a57ac 100644 --- a/lib/octaspace.rb +++ b/lib/octaspace.rb @@ -26,12 +26,9 @@ require "octaspace/resources/hosting" require "octaspace/client" -# OctaSpace Ruby SDK -# -# Official Ruby client for the OctaSpace API. -# Supports standard and keep-alive (persistent) connection modes, -# automatic URL rotation/failover, retry with exponential backoff, -# and optional Rails integration. +# The official Ruby library for the OctaSpace API: rent compute machines and +# render nodes, manage sessions and the nodes you host, and read GPU market +# statistics. # # @example Quick start # OctaSpace.configure do |config| @@ -42,7 +39,7 @@ # client.compute.list # client.accounts.balance # -# @see https://api-dev.octa.space/swagger +# @see https://github.com/octaspace/ruby-sdk#readme Guides module OctaSpace class << self # Global configuration object @@ -64,7 +61,7 @@ def configure yield configuration end - # Reset global configuration to defaults (useful in tests) + # Resets the global configuration to its defaults and drops the shared client. def reset_configuration! @configuration = Configuration.new @shared_client = nil diff --git a/lib/octaspace/client.rb b/lib/octaspace/client.rb index a0ee11e..b22cd77 100644 --- a/lib/octaspace/client.rb +++ b/lib/octaspace/client.rb @@ -18,13 +18,13 @@ module OctaSpace # pool_size: ENV.fetch("RAILS_MAX_THREADS", 5).to_i # ) # - # @example Multiple API endpoints with failover + # @example Several API hosts with failover (the second one is your own, e.g. a proxy) # client = OctaSpace::Client.new( # api_key: ENV["OCTA_API_KEY"], - # base_urls: ["https://api-dev.octa.space", "https://mirror.example"] + # base_urls: [OctaSpace::DEFAULT_BASE_URL, "https://octaspace-proxy.your-company.example"] # ) # - # @example Without API key (public endpoints only) + # @example Without an API key (network and GPU statistics, machine listings) # client = OctaSpace::Client.new # client.network.info # @@ -41,16 +41,17 @@ class Client attr_reader :compute # @return [Resources::Hosting] nodes of the account and their prices attr_reader :hosting - # @return [Resources::Network] public platform statistics + # @return [Resources::Network] platform statistics; no API key needed attr_reader :network # @return [Resources::Render] render nodes: list, start, start_batch attr_reader :render # @return [Resources::Sessions] running sessions: list, get, logs, stop, wait_ready, wait_stopped attr_reader :sessions - # @return [Resources::Stats] public GPU market statistics + # @return [Resources::Stats] GPU market statistics; no API key needed attr_reader :stats - # @param api_key [String, nil] API key for authentication (optional for public endpoints) + # @param api_key [String, nil] API key of your OctaSpace account (+octa_ak_…+); see + # the README for the calls that work without one # @param opts [Hash] Per-instance configuration overrides. # Any attribute from OctaSpace::Configuration can be passed here. def initialize(api_key: nil, transport: nil, **opts) diff --git a/lib/octaspace/configuration.rb b/lib/octaspace/configuration.rb index a7bcc5c..0285937 100644 --- a/lib/octaspace/configuration.rb +++ b/lib/octaspace/configuration.rb @@ -4,7 +4,8 @@ module OctaSpace # Default API base URL DEFAULT_BASE_URL = "https://api-dev.octa.space" - # Prefix of account API keys (created in Cube → Profile → Credentials) + # Prefix of account API keys (created in the OctaSpace dashboard under + # Account → Credentials → API Keys) API_KEY_PREFIX = "octa_ak_" # Global and per-client configuration for the OctaSpace SDK @@ -20,18 +21,17 @@ class Configuration # --- Authentication --- # Account API key (+octa_ak_…+), sent as +Authorization: Bearer +. - # Optional: public endpoints (network, GPU statistics, machine listings) - # work without it. A pasted value that already starts with "Bearer " is - # accepted. + # Network and GPU statistics and the machine listings work without it. A + # value that already starts with "Bearer " is accepted. # @return [String, nil] attr_reader :api_key # --- Connection --- - # @return [String] Base URL for the API (single endpoint) + # @return [String] Base URL of the API (one host) attr_accessor :base_url - # Ordered list of API endpoints. GET requests fail over to the next URL + # Ordered list of API hosts. GET requests fail over to the next URL # on network errors; requests that change state are sent to one URL only. # Takes precedence over {#base_url}. # @return [Array, nil] @@ -78,8 +78,8 @@ class Configuration # @return [Float] Longest backoff delay in seconds (without Retry-After) attr_accessor :max_retry_interval - # A server-supplied Retry-After longer than this (e.g. the 5-minute - # per-session restart cooldown) is raised to the caller instead of waited for. + # A Retry-After longer than this is raised to the caller instead of waited + # for. # @return [Integer] seconds attr_accessor :max_retry_after @@ -95,9 +95,9 @@ class Configuration # --- Logging --- - # Logger for one line per request (method, path, status, duration). + # Logger for one line per request (method, URL, status, duration). # Headers and bodies are never logged: they carry the API key and, - # for sessions, URLs with credentials. + # for sessions, URLs that grant access. # @return [Logger, nil] attr_accessor :logger @@ -106,7 +106,7 @@ class Configuration # --- SSL --- - # @return [Boolean] Verify SSL certificates (set false only in development/test) + # @return [Boolean] Verify the server's TLS certificate attr_accessor :ssl_verify # --- Identity --- diff --git a/lib/octaspace/errors.rb b/lib/octaspace/errors.rb index cefa840..9466e0b 100644 --- a/lib/octaspace/errors.rb +++ b/lib/octaspace/errors.rb @@ -55,7 +55,7 @@ def body = response&.data # (for example "tail must be 1..1000" or "Invalid node number"). class ValidationError < ApiError; end - # 401: the API key is missing or invalid. Keys start with +octa_ak_+. + # 401: the API key is missing, wrong or deleted. Keys start with +octa_ak_+. # Also raised before any request when an endpoint needs a key and the # client has none. class AuthenticationError < ApiError; end @@ -83,8 +83,7 @@ class RateLimitError < ApiError; end class ServerError < ApiError; end class BadGatewayError < ServerError; end # 502 - # 503: a backend the API depends on (render nodes, statistics, …) is - # temporarily unavailable. The request itself was fine; retry later. + # 503: the service is temporarily unavailable; retry later. class ServiceUnavailableError < ServerError; end class GatewayTimeoutError < ServerError; end # 504 @@ -116,7 +115,7 @@ def self.error_for(response, api_key: nil) end message = error_message(response) if response.status == 401 && api_key && !api_key.start_with?(API_KEY_PREFIX) - message += " (API keys start with \"#{API_KEY_PREFIX}\"; create one in Cube → Profile → Credentials)" + message += " (API keys start with \"#{API_KEY_PREFIX}\"; create one in the OctaSpace dashboard under Account → Credentials → API Keys)" end klass.new(message, response: response) end diff --git a/lib/octaspace/json_null.rb b/lib/octaspace/json_null.rb index ba82d6a..98a8015 100644 --- a/lib/octaspace/json_null.rb +++ b/lib/octaspace/json_null.rb @@ -1,13 +1,8 @@ # frozen_string_literal: true module OctaSpace - # Repairs for values the 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. + # Normalizes values of listings and statistics: the string "null" is read as + # nil, and a numeric string in an integer GPU field as a number. # # @api private module JsonNull diff --git a/lib/octaspace/middleware/url_rotator.rb b/lib/octaspace/middleware/url_rotator.rb index 7881691..1ace516 100644 --- a/lib/octaspace/middleware/url_rotator.rb +++ b/lib/octaspace/middleware/url_rotator.rb @@ -10,13 +10,13 @@ module Middleware # # @example # rotator = OctaSpace::Middleware::UrlRotator.new([ - # "https://api-dev.octa.space", - # "https://mirror.example" + # OctaSpace::DEFAULT_BASE_URL, + # "https://octaspace-proxy.your-company.example" # your own second host # ]) # # url = rotator.next_url # rotator.mark_failed(url) - # rotator.stats # => { total: 2, available: 1, failed: ["https://api-dev.octa.space"] } + # rotator.stats # => { total: 2, available: 1, failed: [OctaSpace::DEFAULT_BASE_URL] } class UrlRotator # Seconds a failed URL is excluded from rotation FAILURE_COOLDOWN = 30 diff --git a/lib/octaspace/model.rb b/lib/octaspace/model.rb index 47dc6ca..c29336f 100644 --- a/lib/octaspace/model.rb +++ b/lib/octaspace/model.rb @@ -4,14 +4,13 @@ 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. + # objects are wrapped in their own model classes. Fields without a reader + # are kept and reachable with {#[]} and {#to_h}. # # @example # machine = client.compute.list.first # machine.node_number # => "7KQ4-M2XD-4" - # machine.gpus.first.model # => "NVIDIA GeForce RTX 3090" + # machine.gpus.first.model # => "NVIDIA GeForce RTX 4090" # machine[:new_field] # => value of a field the SDK has no reader for # machine.to_h # => the JSON object as parsed class Model diff --git a/lib/octaspace/railtie.rb b/lib/octaspace/railtie.rb index d4d72e3..92080a9 100644 --- a/lib/octaspace/railtie.rb +++ b/lib/octaspace/railtie.rb @@ -14,8 +14,7 @@ module OctaSpace # end class Railtie < ::Rails::Railtie initializer "octaspace.configure" do - # No-op: users call OctaSpace.configure {} directly in their initializer. - # This hook exists for future extensions (e.g., auto-configure from credentials). + # Configure the SDK with OctaSpace.configure in your own initializer. end # Gracefully shut down the shared client's persistent connection pools diff --git a/lib/octaspace/resources/accounts.rb b/lib/octaspace/resources/accounts.rb index 3b2b39a..3ee6048 100644 --- a/lib/octaspace/resources/accounts.rb +++ b/lib/octaspace/resources/accounts.rb @@ -5,14 +5,13 @@ module Resources # The account the API key belongs to # # @example - # client.accounts.balance.formatted # => "$2.147661" + # client.accounts.balance.formatted # => "$12.500000" class Accounts < Base # Account balance. # GET /accounts/balance # - # The API has no "who am I" endpoint, so this is also the way to check - # that an API key is valid. It is read from a replica, so a recent charge - # or top-up may show up with a delay. + # Also a simple way to check that an API key works. A recent charge or + # deposit can take a moment to appear. # @return [OctaSpace::Types::Balance] def balance Types::Balance.new(get("/accounts/balance").data) diff --git a/lib/octaspace/resources/base.rb b/lib/octaspace/resources/base.rb index abe0ce1..7bbad30 100644 --- a/lib/octaspace/resources/base.rb +++ b/lib/octaspace/resources/base.rb @@ -9,7 +9,7 @@ module Resources # Delegates HTTP methods to the transport layer and provides # helpers shared by the resources. class Base - # Integer fields of a listing's GPU objects that nodes may send as strings + # Integer fields of a listing's GPU objects, read as numbers 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] @@ -62,8 +62,8 @@ def batch_items(items, allowed) end end - # Machine listings: "null" strings become nil and string GPU integers - # become numbers, so one misreporting node does not break the listing. + # Machine listings: "null" strings become nil and numeric GPU strings + # become numbers. def normalize_machines(data) JsonNull.gpu_numbers!(JsonNull.null_strings!(Array(data)), GPU_INTEGER_FIELDS) end diff --git a/lib/octaspace/resources/compute.rb b/lib/octaspace/resources/compute.rb index 44cc6dc..07f0bdf 100644 --- a/lib/octaspace/resources/compute.rb +++ b/lib/octaspace/resources/compute.rb @@ -19,25 +19,25 @@ class Compute < Base # 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. + # Works without an API key, but is then refreshed about once 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) + # POST /services/compute # # 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. "7KQ4-M2XD-4" - # (case, dashes and spaces are ignored by the API) + # (case, dashes and spaces are ignored) # @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 disk_size [Integer, nil] disk, GB (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) @@ -56,7 +56,7 @@ def start(**params) end # Starts several sessions at once. - # POST /services/compute (array → 200) + # POST /services/compute # # 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 @@ -70,7 +70,7 @@ def start_batch(items) # Recreates the session's container on the same node, keeping the rent, # uuid and prices. - # POST /services/compute/{uuid}/restart (→ 202) + # POST /services/compute/{uuid}/restart # # Follow the progress with +client.sessions.get(uuid)+; if the new # parameters cannot be applied, the session stops. @@ -80,8 +80,7 @@ def start_batch(items) # 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. + # 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 diff --git a/lib/octaspace/resources/hosting.rb b/lib/octaspace/resources/hosting.rb index 940cf71..0585b43 100644 --- a/lib/octaspace/resources/hosting.rb +++ b/lib/octaspace/resources/hosting.rb @@ -10,13 +10,13 @@ module Resources class Hosting < Base PRICE_FIELDS = %i[base storage traffic min_rent_minutes].freeze - # GPU fields nodes may send as strings in +system.gpus+ + # Integer GPU fields of +system.gpus+, read as numbers HOSTING_GPU_FIELDS = (GPU_INTEGER_FIELDS + %w[mem_free_mb fan_speed gpu_temperature gpu_utilization power_draw_watt]).freeze # Online nodes of the account with their state, hardware and prices. # GET /hosting/nodes # - # Offline nodes are not listed, and there is no per-node endpoint. + # Offline nodes are not listed. # @return [Array] def nodes data = JsonNull.null_strings!(Array(get("/hosting/nodes").data)) @@ -25,7 +25,7 @@ def nodes end # Updates some prices of a node. - # PATCH /hosting/nodes/{number}/prices (→ 204) + # PATCH /hosting/nodes/{number}/prices # # Only the given fields are sent; at least one is required. Amounts are # integer micro-USD (convert dollars with {OctaSpace::Money.from_usd}). @@ -34,8 +34,8 @@ def nodes # {OctaSpace::ValidationError}, and an unknown node with # {OctaSpace::NotFoundError}. # - # 204 means the prices are stored; listings (+hosting.nodes+, - # +compute.list+) show them after replication, usually within seconds. + # The listings (+hosting.nodes+, +compute.list+) show new prices within + # seconds. # # @param number [String] node number; case and dashes are ignored # @param base [Integer, nil] diff --git a/lib/octaspace/resources/network.rb b/lib/octaspace/resources/network.rb index 173a4f2..a998450 100644 --- a/lib/octaspace/resources/network.rb +++ b/lib/octaspace/resources/network.rb @@ -2,7 +2,7 @@ module OctaSpace module Resources - # Public platform statistics; no API key needed + # Platform statistics; no API key needed # # @example # client.network.info.nodes.online diff --git a/lib/octaspace/resources/render.rb b/lib/octaspace/resources/render.rb index 7b8f04a..2b8fe19 100644 --- a/lib/octaspace/resources/render.rb +++ b/lib/octaspace/resources/render.rb @@ -13,14 +13,15 @@ class Render < Base # Nodes available for render. # GET /services/render # - # Works without an API key; with a key the listing is fresher. + # Works without an API key, but is then refreshed about once an hour; + # with a key it is at most 10 seconds old. # @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) + # POST /services/render # # While render is temporarily unavailable this raises # {OctaSpace::ServiceUnavailableError} (503). Never retried automatically. @@ -31,11 +32,10 @@ def start(node_number:) end # Rents several render nodes at once. - # POST /services/render (array → 200) + # POST /services/render # - # Unlike {#start}, a temporarily unavailable service is reported per item - # (+status: 1+, +reason: "Service is temporarily unavailable"+), not as - # an HTTP 503. + # Unlike {#start}, a temporarily unavailable service is reported in each + # item's +reason+ instead of raising {OctaSpace::ServiceUnavailableError}. # @param items [Array] each +{node_number:}+ with an optional +:id+ # @return [Array] def start_batch(items) diff --git a/lib/octaspace/resources/sessions.rb b/lib/octaspace/resources/sessions.rb index 90d92d8..da528fe 100644 --- a/lib/octaspace/resources/sessions.rb +++ b/lib/octaspace/resources/sessions.rb @@ -49,14 +49,14 @@ def get(uuid) # # 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) + # @param tail [Integer, nil] last lines of container output, 1..1000 (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) + # POST /services/{uuid}/stop # # Stopping is asynchronous: the session is gone once {#get} raises # NotFoundError (see {#wait_stopped}). Repeating the call while the diff --git a/lib/octaspace/resources/stats.rb b/lib/octaspace/resources/stats.rb index 82bd497..e3c5797 100644 --- a/lib/octaspace/resources/stats.rb +++ b/lib/octaspace/resources/stats.rb @@ -2,12 +2,12 @@ module OctaSpace module Resources - # Public GPU market statistics; no API key needed + # GPU market statistics; no API key needed # # @example # client.stats.gpu_current.gpus.first.rented_price.p50 - # client.stats.gpu_history(gpu_name: "NVIDIA GeForce RTX 3090", from: Time.now - 7 * 86_400) - # .fetch("NVIDIA GeForce RTX 3090").points + # client.stats.gpu_history(gpu_name: "NVIDIA GeForce RTX 4090", from: Time.now - 7 * 86_400) + # .fetch("NVIDIA GeForce RTX 4090").points class Stats < Base # GPU models on the market now, most common first. # GET /stats/gpu/current diff --git a/lib/octaspace/response.rb b/lib/octaspace/response.rb index b6db90a..45721ac 100644 --- a/lib/octaspace/response.rb +++ b/lib/octaspace/response.rb @@ -4,8 +4,7 @@ module OctaSpace # Rate limit state reported by the API in the +RateLimit-Limit+, - # +RateLimit-Remaining+ and +RateLimit-Reset+ headers (IETF draft format, - # no +X-+ prefix). + # +RateLimit-Remaining+ and +RateLimit-Reset+ headers. # # The API keeps separate windows (anonymous per IP, authenticated per # account, and a stricter one for starting and restarting services), so the diff --git a/lib/octaspace/transport/faraday_transport.rb b/lib/octaspace/transport/faraday_transport.rb index 8325624..8346b5d 100644 --- a/lib/octaspace/transport/faraday_transport.rb +++ b/lib/octaspace/transport/faraday_transport.rb @@ -8,7 +8,7 @@ module Transport # Standard HTTP transport built on Faraday # # - +Authorization: Bearer + per endpoint auth mode - # - JSON request encoding and response parsing (empty 202/204 bodies are fine) + # - JSON request encoding and response parsing # - Retries with exponential backoff and jitter for GET and idempotent # requests only, honouring Retry-After up to {Configuration#max_retry_after} # - Failover across several base URLs for GET requests on network errors; @@ -156,7 +156,7 @@ def build_headers(method, path, auth, body) elsif auth == :required raise AuthenticationError, "api_key is required for #{method.upcase} #{path}: pass an API key " \ - "(#{API_KEY_PREFIX}…) issued in Cube → Profile → Credentials" + "(#{API_KEY_PREFIX}…) from Account → Credentials → API Keys in the OctaSpace dashboard" end end headers diff --git a/lib/octaspace/types/account.rb b/lib/octaspace/types/account.rb index 3d98508..cac36be 100644 --- a/lib/octaspace/types/account.rb +++ b/lib/octaspace/types/account.rb @@ -15,7 +15,7 @@ class Balance < Model # @return [Rational] the balance in currency units, exactly def to_usd = Money.to_usd(balance, decimals: decimals) - # @return [String] e.g. "$2.147661" + # @return [String] e.g. "$12.500000" def formatted = Money.format(balance, decimals: decimals) def negative? = balance.negative? diff --git a/lib/octaspace/types/machines.rb b/lib/octaspace/types/machines.rb index 0c502f9..c9ee4f5 100644 --- a/lib/octaspace/types/machines.rb +++ b/lib/octaspace/types/machines.rb @@ -2,7 +2,7 @@ module OctaSpace module Types - # A GPU of a machine listing (+GpuNvidia+ in the API spec) + # A GPU of a machine listing class Gpu < Model # @!attribute [r] model # @return [String] GPU model name @@ -22,8 +22,8 @@ class Gpu < Model 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 {#[]}. + # Node reliability. {#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 diff --git a/lib/octaspace/types/session.rb b/lib/octaspace/types/session.rb index bbe00f5..32846c3 100644 --- a/lib/octaspace/types/session.rb +++ b/lib/octaspace/types/session.rb @@ -40,7 +40,7 @@ class NodeHw < Model # @!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 + # @return [Integer, nil] Mbit/s; nil if unknown 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 @@ -49,11 +49,11 @@ class NodeHw < Model # 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. + # field, and a plain Session for any other service. # Many fields are nil while the session is starting. class Session < Model # @!attribute [r] service - # @return [String] "compute", "render", or a service added later + # @return [String] "compute" or "render" # @!attribute [r] status # @return [String] "starting" or "ready" # @!attribute [r] progress diff --git a/octaspace.gemspec b/octaspace.gemspec index c8df018..ef537ab 100644 --- a/octaspace.gemspec +++ b/octaspace.gemspec @@ -8,16 +8,17 @@ Gem::Specification.new do |spec| spec.authors = ["OctaSpace Team"] spec.email = ["dev@octa.space"] - spec.summary = "Ruby SDK for the OctaSpace API" - spec.description = "Official Ruby client for OctaSpace. " \ - "Keep-alive connections, URL failover, retry with backoff, Rails integration." + spec.summary = "Official Ruby library for the OctaSpace API" + spec.description = "Rent compute machines and render nodes, manage sessions and the nodes " \ + "you host, and read GPU market statistics on OctaSpace from Ruby code." spec.homepage = "https://github.com/octaspace/ruby-sdk" spec.license = "MIT" spec.required_ruby_version = ">= 3.3.0" spec.metadata = { - "changelog_uri" => "#{spec.homepage}/blob/main/CHANGELOG.md", + "changelog_uri" => "#{spec.homepage}/blob/v#{spec.version}/CHANGELOG.md", + "documentation_uri" => "https://rubydoc.info/gems/octaspace/#{spec.version}", "source_code_uri" => spec.homepage, "bug_tracker_uri" => "#{spec.homepage}/issues", "rubygems_mfa_required" => "true"