From 1b4098c7e00e2e48e1b58a2633865c5ec823b1d6 Mon Sep 17 00:00:00 2001 From: secretpray Date: Tue, 3 Nov 2026 12:52:25 +0200 Subject: [PATCH 1/9] chore: drop the playground, modernize CI and dependencies The SDK becomes a bare library on the way to the next API: - remove the Rails playground (test/dummy, bin/playground, lib/octaspace/playground), MockTransport, the Rails Engine and the Appraisal/Rails matrix; the Railtie stays (no Rails dependency) - require Ruby >= 3.3 (3.2 is EOL); CI tests 3.3, 3.4 and 4.0 - CI: actions pinned to commit SHAs, read-only token, concurrency, runs on main and next and on every PR, strict gem build, bundler-audit job - Dependabot for bundler and actions, targeting next, 3-day cooldown - faraday >= 2.14.3 (fixes published advisories); minitest 6, current webmock/standard/rake; railties and puma removed - docs/GIT_WORKFLOW_NEXT.md: how to work with the next branch --- .github/dependabot.yml | 25 ++ .github/workflows/ci.yml | 82 +++-- Appraisals | 11 - Gemfile | 18 +- README.md | 49 +-- bin/playground | 19 - docs/GIT_WORKFLOW_NEXT.md | 107 ++++++ gemfiles/rails_7_1.gemfile | 16 - gemfiles/rails_7_2.gemfile | 16 - gemfiles/rails_8_0.gemfile | 16 - lib/octaspace/engine.rb | 16 - lib/octaspace/playground/payload_presets.rb | 60 --- lib/octaspace/playground/payload_presets.yml | 58 --- lib/octaspace/playground/smoke_runner.rb | 204 ----------- lib/octaspace/transport/mock_transport.rb | 138 ------- octaspace.gemspec | 7 +- test/dummy/Rakefile | 4 - .../app/controllers/application_controller.rb | 341 ------------------ .../playground/accounts_controller.rb | 15 - .../controllers/playground/apps_controller.rb | 9 - .../controllers/playground/base_controller.rb | 78 ---- .../playground/dashboard_controller.rb | 17 - .../playground/diagnostics_controller.rb | 329 ----------------- .../playground/idle_jobs_controller.rb | 32 -- .../playground/network_controller.rb | 9 - .../playground/nodes_controller.rb | 22 -- .../playground/request_logs_controller.rb | 10 - .../playground/services_controller.rb | 12 - .../playground/sessions_controller.rb | 53 --- .../playground/settings_controller.rb | 41 --- .../app/views/layouts/application.html.erb | 180 --------- .../views/playground/accounts/show.html.erb | 66 ---- .../app/views/playground/apps/show.html.erb | 95 ----- .../views/playground/dashboard/show.html.erb | 111 ------ .../playground/diagnostics/show.html.erb | 137 ------- .../views/playground/idle_jobs/show.html.erb | 76 ---- .../views/playground/network/show.html.erb | 80 ---- .../app/views/playground/nodes/index.html.erb | 83 ----- .../app/views/playground/nodes/show.html.erb | 22 -- .../views/playground/services/show.html.erb | 134 ------- .../views/playground/sessions/index.html.erb | 138 ------- .../playground/shared/_error_card.html.erb | 28 -- .../playground/shared/_json_viewer.html.erb | 8 - .../shared/_node_detail_panel.html.erb | 58 --- .../shared/_resource_panel.html.erb | 52 --- .../shared/_sessions_table.html.erb | 102 ------ test/dummy/bin/rails | 6 - test/dummy/config.ru | 6 - test/dummy/config/application.rb | 22 -- test/dummy/config/boot.rb | 5 - test/dummy/config/environment.rb | 5 - test/dummy/config/initializers/octaspace.rb | 10 - test/dummy/config/routes.rb | 24 -- test/dummy/public/playground.css | 38 -- test/dummy/public/playground.js | 145 -------- test/dummy/public/service-worker.js | 1 - test/dummy_diagnostics_test.rb | 146 -------- test/dummy_resources_test.rb | 76 ---- test/dummy_sessions_test.rb | 55 --- test/octaspace/client_test.rb | 4 +- .../playground/payload_presets_test.rb | 33 -- .../octaspace/playground/smoke_runner_test.rb | 36 -- test/octaspace/resources/services_test.rb | 20 +- .../transport/mock_transport_test.rb | 39 -- test/test_helper.rb | 3 - 65 files changed, 212 insertions(+), 3646 deletions(-) create mode 100644 .github/dependabot.yml delete mode 100644 Appraisals delete mode 100755 bin/playground create mode 100644 docs/GIT_WORKFLOW_NEXT.md delete mode 100644 gemfiles/rails_7_1.gemfile delete mode 100644 gemfiles/rails_7_2.gemfile delete mode 100644 gemfiles/rails_8_0.gemfile delete mode 100644 lib/octaspace/engine.rb delete mode 100644 lib/octaspace/playground/payload_presets.rb delete mode 100644 lib/octaspace/playground/payload_presets.yml delete mode 100644 lib/octaspace/playground/smoke_runner.rb delete mode 100644 lib/octaspace/transport/mock_transport.rb delete mode 100644 test/dummy/Rakefile delete mode 100644 test/dummy/app/controllers/application_controller.rb delete mode 100644 test/dummy/app/controllers/playground/accounts_controller.rb delete mode 100644 test/dummy/app/controllers/playground/apps_controller.rb delete mode 100644 test/dummy/app/controllers/playground/base_controller.rb delete mode 100644 test/dummy/app/controllers/playground/dashboard_controller.rb delete mode 100644 test/dummy/app/controllers/playground/diagnostics_controller.rb delete mode 100644 test/dummy/app/controllers/playground/idle_jobs_controller.rb delete mode 100644 test/dummy/app/controllers/playground/network_controller.rb delete mode 100644 test/dummy/app/controllers/playground/nodes_controller.rb delete mode 100644 test/dummy/app/controllers/playground/request_logs_controller.rb delete mode 100644 test/dummy/app/controllers/playground/services_controller.rb delete mode 100644 test/dummy/app/controllers/playground/sessions_controller.rb delete mode 100644 test/dummy/app/controllers/playground/settings_controller.rb delete mode 100644 test/dummy/app/views/layouts/application.html.erb delete mode 100644 test/dummy/app/views/playground/accounts/show.html.erb delete mode 100644 test/dummy/app/views/playground/apps/show.html.erb delete mode 100644 test/dummy/app/views/playground/dashboard/show.html.erb delete mode 100644 test/dummy/app/views/playground/diagnostics/show.html.erb delete mode 100644 test/dummy/app/views/playground/idle_jobs/show.html.erb delete mode 100644 test/dummy/app/views/playground/network/show.html.erb delete mode 100644 test/dummy/app/views/playground/nodes/index.html.erb delete mode 100644 test/dummy/app/views/playground/nodes/show.html.erb delete mode 100644 test/dummy/app/views/playground/services/show.html.erb delete mode 100644 test/dummy/app/views/playground/sessions/index.html.erb delete mode 100644 test/dummy/app/views/playground/shared/_error_card.html.erb delete mode 100644 test/dummy/app/views/playground/shared/_json_viewer.html.erb delete mode 100644 test/dummy/app/views/playground/shared/_node_detail_panel.html.erb delete mode 100644 test/dummy/app/views/playground/shared/_resource_panel.html.erb delete mode 100644 test/dummy/app/views/playground/shared/_sessions_table.html.erb delete mode 100755 test/dummy/bin/rails delete mode 100644 test/dummy/config.ru delete mode 100644 test/dummy/config/application.rb delete mode 100644 test/dummy/config/boot.rb delete mode 100644 test/dummy/config/environment.rb delete mode 100644 test/dummy/config/initializers/octaspace.rb delete mode 100644 test/dummy/config/routes.rb delete mode 100644 test/dummy/public/playground.css delete mode 100644 test/dummy/public/playground.js delete mode 100644 test/dummy/public/service-worker.js delete mode 100644 test/dummy_diagnostics_test.rb delete mode 100644 test/dummy_resources_test.rb delete mode 100644 test/dummy_sessions_test.rb delete mode 100644 test/octaspace/playground/payload_presets_test.rb delete mode 100644 test/octaspace/playground/smoke_runner_test.rb delete mode 100644 test/octaspace/transport/mock_transport_test.rb diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c3338ba --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,25 @@ +version: 2 + +# Development happens on `next` until the new API is released; switch +# target-branch back to `main` when `next` is merged. +updates: + - package-ecosystem: bundler + directory: / + target-branch: next + schedule: + interval: weekly + # Supply-chain delay: do not pick up a release in its first days. + cooldown: + default-days: 3 + groups: + dev-dependencies: + dependency-type: development + update-types: [minor, patch] + + - package-ecosystem: github-actions + directory: / + target-branch: next + schedule: + interval: weekly + cooldown: + default-days: 3 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d7a138..9f64d7b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,55 +2,81 @@ name: CI on: push: - branches: [main] + branches: [main, next] + # Every pull request, including stacked ones whose base is another feature branch. pull_request: workflow_call: +# Least privilege: jobs only read the repository. +permissions: + contents: read + +# A new push to the same PR or branch cancels the run still in progress. +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +# Actions are pinned to full commit SHAs (tag in the comment); Dependabot keeps them current. jobs: + test: + name: Test (Ruby ${{ matrix.ruby }}) + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + # Ruby lines still maintained upstream; 3.3 is the minimum in the gemspec. + ruby: ["3.3", "3.4", "4.0"] + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: ruby/setup-ruby@de492cf36cbab201753d720ce4f4e336cd688c94 # v1.336.0 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + + - name: Test + run: bundle exec rake test + + - name: Build gem + run: gem build octaspace.gemspec --strict + lint: name: Lint (StandardRB) runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - - uses: ruby/setup-ruby@v1 + - uses: ruby/setup-ruby@de492cf36cbab201753d720ce4f4e336cd688c94 # v1.336.0 with: - ruby-version: "3.4" + ruby-version: "4.0" bundler-cache: true - name: StandardRB run: bundle exec standardrb - test: - name: Ruby ${{ matrix.ruby }} / ${{ matrix.gemfile }} + security: + name: Dependency audit runs-on: ubuntu-latest - timeout-minutes: 20 - - strategy: - fail-fast: false - matrix: - ruby: - - "3.2" - - "3.3" - - "3.4" - gemfile: - - gemfiles/rails_7_1.gemfile - - gemfiles/rails_7_2.gemfile - - gemfiles/rails_8_0.gemfile - - env: - BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }} - BUNDLE_PATH_RELATIVE_TO_CWD: true + timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - - uses: ruby/setup-ruby@v1 + - uses: ruby/setup-ruby@de492cf36cbab201753d720ce4f4e336cd688c94 # v1.336.0 with: - ruby-version: ${{ matrix.ruby }} + ruby-version: "4.0" bundler-cache: true - - name: Test - run: bundle exec rake test + # Fails on any advisory for the resolved gems (fresh advisory database each run). + - name: bundler-audit + run: bundle exec bundle-audit check --update diff --git a/Appraisals b/Appraisals deleted file mode 100644 index 01f262b..0000000 --- a/Appraisals +++ /dev/null @@ -1,11 +0,0 @@ -appraise "rails-7-1" do - gem "rails", "~> 7.1.0" -end - -appraise "rails-7-2" do - gem "rails", "~> 7.2.0" -end - -appraise "rails-8-0" do - gem "rails", "~> 8.0.0" -end diff --git a/Gemfile b/Gemfile index 61e9093..fa36391 100644 --- a/Gemfile +++ b/Gemfile @@ -5,16 +5,12 @@ source "https://rubygems.org" gemspec # Development & testing -gem "minitest", "~> 5.0" -gem "webmock", "~> 3.0" -gem "standard", "~> 1.0" # StandardRB linter -gem "rake", "~> 13.0" -gem "appraisal" +gem "minitest", "~> 6.0" +gem "webmock", "~> 3.34" +gem "standard", "~> 1.57" # StandardRB linter +gem "rake", "~> 13.3" +gem "bundler-audit", "~> 0.9", require: false # Optional persistent mode (needed for persistent_transport tests) -gem "faraday-net_http_persistent", "~> 2.1" -gem "connection_pool", "~> 2.4" - -# Rails (for Railtie/Engine/dummy app tests) -gem "railties", ">= 7.1" -gem "puma", "~> 6.0" +gem "faraday-net_http_persistent", "~> 2.3" +gem "connection_pool", ">= 2.4", "< 4" diff --git a/README.md b/README.md index 2b73517..bfd20ef 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,7 @@ Official Ruby SDK for the [OctaSpace API](https://api.octa.space/api-docs). - **Typed error hierarchy** — 12 exception classes mapped from HTTP status codes - **`on_request` / `on_response` hooks** — for logging, tracing, APM - **Rails integration** — Railtie, shared client, graceful shutdown at_exit -- **Playground app** — `bin/playground` with live diagnostics page -- **Ruby ≥ 3.2**, Rails 7.1 / 7.2 / 8.0 +- **Ruby ≥ 3.3**; optional Railtie for Rails apps ## Installation @@ -258,25 +257,6 @@ node.id # => 123 Available types: `Node`, `Account`, `Balance`, `Session`. -## Playground App - -Interactive demo for manual testing against a real API key: - -```bash -OCTA_API_KEY=your_key bin/playground -# → http://localhost:3000 -``` - -Pages: - -| Route | Content | -|---|---| -| `/playground/account` | Profile + balance | -| `/playground/nodes` | Node list with state badges | -| `/playground/sessions` | Current + recent sessions, including live-format quirks | -| `/playground/services` | Marketplace catalogs for MR, Render, and VPN | -| `/playground/diagnostics` | Direct SDK method runner for contracts, payloads, transport mode, and pool stats | - ## Development ### Gem Development @@ -304,33 +284,6 @@ irb > client_auth.accounts.profile.data # Retrieving profile data (email, ID, etc.) ``` -### Running tests against multiple Rails versions - -```bash -bundle exec appraisal rails-7-1 rake test -bundle exec appraisal rails-7-2 rake test -bundle exec appraisal rails-8-0 rake test -``` - -### Dummy Application (Playground) - -The repository includes a Rails "Dummy" application for manual testing and UI prototyping. It is located in `test/dummy`. - -To run the dummy app: - -1. Ensure you have an API key: `export OCTA_API_KEY=your_key` -2. Run the playground script: `bin/playground` (starts Puma on port 3000) -3. Visit `http://localhost:3000/playground/diagnostics` - -You can also run it manually from the directory: - -```bash -cd test/dummy -bin/rails server -``` - -The dummy app is configured to use the local version of the gem. It is **not** included in the published gem package. - ## Packaging and Publishing See [PUBLISHING.md](PUBLISHING.md) for instructions on how to package and release new versions of the gem. diff --git a/bin/playground b/bin/playground deleted file mode 100755 index a797c4a..0000000 --- a/bin/playground +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# Launch the dummy Rails playground app -# Run: bin/playground -# -# Set OCTA_API_KEY in your environment before starting: -# OCTA_API_KEY=your_key bin/playground - -dummy_path = File.expand_path("../test/dummy", __dir__) - -unless Dir.exist?(dummy_path) - warn "Dummy app not found at #{dummy_path}" - exit 1 -end - -Dir.chdir(dummy_path) do - exec "bin/rails server #{ARGV.join(" ")}" -end diff --git a/docs/GIT_WORKFLOW_NEXT.md b/docs/GIT_WORKFLOW_NEXT.md new file mode 100644 index 0000000..11a260d --- /dev/null +++ b/docs/GIT_WORKFLOW_NEXT.md @@ -0,0 +1,107 @@ +# Работа с веткой `next` + +`next` — ветка перехода SDK на новое API OctaSpace (стенд `https://api-dev.octa.space`). +Все правки под новое API идут PR-ами в `next`; слияние `next → main` — отдельным решением, +когда новое API выйдет в прод. `main` остаётся веткой текущего (legacy) SDK и хотфиксов. +Так же устроены Cube, go-sdk, js-sdk и octa-cli. + +На GitHub ветка по умолчанию — **`main`**. Поэтому PR, открытый без явной базы, +уходит в `main`, а не в `next`. + +## `main` и `next` — по шагам + +| Шаг | Работа с `main` | Работа с `next` | +|---|---|---| +| Обновить ветки | `git fetch origin` | `git fetch origin` — то же | +| Перейти на базовую ветку | `git switch main` | `git switch next` | +| Подтянуть свежий код | `git pull` | `git pull` — то же (`next` отслеживает `origin/next`) | +| Новая ветка задачи | `git switch -c feat/x` (с `main`) | `git switch -c feat/x origin/next` ← **главное отличие** | +| Коммит | `git add …` → `git commit` | то же | +| Первый пуш ветки | `git push -u origin feat/x` | то же (пушится ветка задачи, не `next`) | +| Подтянуть свежую базу | `git rebase origin/main` | `git rebase origin/next` | +| PR через `gh` | `gh pr create` | `gh pr create --base next` ← **обязательно** | +| PR на сайте | база `main` подставится сама | вручную сменить **base: main → next** | +| Диф ветки против базы | `git diff main...HEAD` | `git diff origin/next...HEAD` | +| После слияния PR | `git switch main && git pull` | `git switch next && git pull` | + +`git switch -c feat/x origin/next` создаёт ветку задачи прямо от удалённой `next`, +даже если сейчас вы стоите на `main` и локальной `next` у вас нет. Ветка сразу +отслеживает `origin/next`; первый `git push -u origin feat/x` переключает её на +собственную удалённую ветку. + +## Типовой цикл задачи + +```bash +git fetch origin +git switch -c feat/my-task origin/next # от свежей next, даже если сейчас стоите на main +# ... правки ... +bundle exec rake test && bundle exec standardrb +git add -A && git commit -m "…" +git push -u origin feat/my-task +gh pr create --base next # без --base PR откроется в main +``` + +Если пока вы работали, в `next` что-то влили — обновить ветку перед PR: + +```bash +git fetch origin +git rebase origin/next +git push --force-with-lease # после rebase обычный push откажет +``` + +## Зависимые PR (стек) + +Если задача B строится на ещё не влитой задаче A, ветку B создают от A, а PR открывают +с базой A: `gh pr create --base feat/a`. Тогда в PR видна только разница B. + +⚠️ **Не удаляйте ветку A при слиянии** (`gh pr merge --delete-branch` или кнопка +«Delete branch»), пока на неё смотрит PR B: GitHub **закроет** PR B, а не перенацелит его. +Сливать стек по одному PR, в таком порядке: + +```bash +gh pr merge --merge # без --delete-branch +gh pr edit --base next # перенацелить следующий PR +git push origin --delete feat/a # теперь ветку A можно удалить +``` + +Если PR B всё же закрылся: восстановить ветку A на её последнем коммите +(`git push origin :refs/heads/feat/a`), затем `gh pr reopen ` и +`gh pr edit --base next`. + +## Ошибки, которые легко сделать + +1. **PR открылся в `main`.** Забыли `--base next` или не сменили base на сайте — + в PR вся разница `next` против `main`. + Исправить: `gh pr edit --base next`. +2. **Ветка создана от `main`.** `git switch -c feat/x`, стоя на `main`, даёт ветку + от `main`, и PR в `next` притащит лишние коммиты. Поэтому базу всегда указывать + явно (`origin/next`), а не полагаться на текущую ветку. + Исправить: `git rebase --onto origin/next origin/main feat/x`. +3. **Прямой push в `next`.** Пушить только ветки задач; в `next` — только через PR, + как и в `main`. +4. **Хотфиксы в `main` не доходят до `next`.** Срочные исправления legacy-SDK идут + веткой от `main` с PR в `main`. Чтобы они не потерялись, `main` периодически + вливается в `next` отдельным PR: + + ```bash + git switch -c chore/sync-main-into-next origin/next + git merge origin/main + git push -u origin chore/sync-main-into-next + gh pr create --base next + ``` + +5. **Устаревшая локальная `next`.** Она обновляется только по `git pull`. Для новой + ветки и rebase надёжнее брать `origin/next` сразу после `git fetch`. + +## CI и релизы + +- CI (`.github/workflows/ci.yml`) запускается на push в `main` и `next` и на любой PR, включая PR в стеке. +- Dependabot открывает PR обновлений в `next` (`target-branch` в `.github/dependabot.yml`). + После слияния `next → main` вернуть `target-branch: main`. +- Гем из `next` в RubyGems не публикуется, пока новое API не выйдет в прод. + +## Если `next` — надолго + +Можно переключить ветку по умолчанию на GitHub (Settings → Branches) на `next`: +PR будут открываться в неё сами, и ошибка 1 исчезнет. Это меняет базу по умолчанию +для всей команды, поэтому решается командой, а не одним разработчиком. diff --git a/gemfiles/rails_7_1.gemfile b/gemfiles/rails_7_1.gemfile deleted file mode 100644 index 7ce12c2..0000000 --- a/gemfiles/rails_7_1.gemfile +++ /dev/null @@ -1,16 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "minitest", "~> 5.0" -gem "webmock", "~> 3.0" -gem "standard", "~> 1.0" -gem "rake", "~> 13.0" -gem "appraisal" -gem "faraday-net_http_persistent", "~> 2.1" -gem "connection_pool", "~> 2.4" -gem "railties", ">= 7.1" -gem "puma", "~> 6.0" -gem "rails", "~> 7.1.0" - -gemspec path: "../" diff --git a/gemfiles/rails_7_2.gemfile b/gemfiles/rails_7_2.gemfile deleted file mode 100644 index e494610..0000000 --- a/gemfiles/rails_7_2.gemfile +++ /dev/null @@ -1,16 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "minitest", "~> 5.0" -gem "webmock", "~> 3.0" -gem "standard", "~> 1.0" -gem "rake", "~> 13.0" -gem "appraisal" -gem "faraday-net_http_persistent", "~> 2.1" -gem "connection_pool", "~> 2.4" -gem "railties", ">= 7.1" -gem "puma", "~> 6.0" -gem "rails", "~> 7.2.0" - -gemspec path: "../" diff --git a/gemfiles/rails_8_0.gemfile b/gemfiles/rails_8_0.gemfile deleted file mode 100644 index 67cd0c4..0000000 --- a/gemfiles/rails_8_0.gemfile +++ /dev/null @@ -1,16 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "minitest", "~> 5.0" -gem "webmock", "~> 3.0" -gem "standard", "~> 1.0" -gem "rake", "~> 13.0" -gem "appraisal" -gem "faraday-net_http_persistent", "~> 2.1" -gem "connection_pool", "~> 2.4" -gem "railties", ">= 7.1" -gem "puma", "~> 6.0" -gem "rails", "~> 8.0.0" - -gemspec path: "../" diff --git a/lib/octaspace/engine.rb b/lib/octaspace/engine.rb deleted file mode 100644 index f5e97b1..0000000 --- a/lib/octaspace/engine.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -module OctaSpace - # Optional Rails Engine - # - # Mount only if you need the built-in playground routes: - # - # # config/routes.rb - # mount OctaSpace::Engine, at: "/octaspace" - # - # This is NOT auto-loaded. Load explicitly when needed: - # require "octaspace/engine" - class Engine < ::Rails::Engine - isolate_namespace OctaSpace - end -end diff --git a/lib/octaspace/playground/payload_presets.rb b/lib/octaspace/playground/payload_presets.rb deleted file mode 100644 index 0bed8ab..0000000 --- a/lib/octaspace/playground/payload_presets.rb +++ /dev/null @@ -1,60 +0,0 @@ -# frozen_string_literal: true - -require "json" -require "yaml" - -module OctaSpace - module Playground - module PayloadPresets - module_function - - def all - @all ||= load_presets - end - - def fetch(key) - all.fetch(key.to_s) - end - - def payload_for(key) - fetch(key).fetch(:payload) - end - - def payload_json_for(key) - JSON.pretty_generate(payload_for(key)) - end - - def reset! - @all = nil - end - - def path - File.expand_path("payload_presets.yml", __dir__) - end - - def load_presets - raw = YAML.safe_load_file(path) || {} - presets = raw.fetch("presets", {}) - - presets.each_with_object({}) do |(key, value), memo| - memo[key] = deep_symbolize(value) - end - end - - def deep_symbolize(value) - case value - when Hash - value.each_with_object({}) do |(key, nested), memo| - memo[key.to_sym] = deep_symbolize(nested) - end - when Array - value.map { |item| deep_symbolize(item) } - else - value - end - end - - private_class_method :load_presets, :deep_symbolize - end - end -end diff --git a/lib/octaspace/playground/payload_presets.yml b/lib/octaspace/playground/payload_presets.yml deleted file mode 100644 index f32387b..0000000 --- a/lib/octaspace/playground/payload_presets.yml +++ /dev/null @@ -1,58 +0,0 @@ -presets: - services.mr.create: - source: - script: js-sdk/packages/sdk/scripts/session-test.ts - note: Mirrors the live MR session payload used by the JS SDK smoke script. - payload: - node_id: 1 - disk_size: 10 - image: ubuntu:24.04 - app: 249b4cb3-3db1-4c06-98a4-772ba88cd81c - - services.render.create: - source: - schema: services_render_request - note: Matches the current OCTA OpenAPI render request schema. - payload: - node_id: 1 - disk_size: 100 - - services.vpn.create: - source: - script: js-sdk/packages/sdk/scripts/session-test.ts - note: Mirrors the live VPN session payload used by the JS SDK smoke script. - payload: - node_id: 1 - subkind: wg - - services.session.stop: - source: - script: js-sdk/packages/sdk/scripts/session-test.ts - note: Matches the stop flow exercised in the JS SDK live session test. - payload: - uuid: sess-abc-123 - score: 5 - - services.session.logs: - source: - live_api: - note: Uses the finished-session recent logs branch confirmed against the live API. - payload: - uuid: sess-abc-123 - recent: true - - idle_jobs.find: - source: - fixture: test/fixtures/idle_jobs/show.json - note: Parameter preset for fetching a specific idle job by node and job identifiers. - payload: - node_id: 69 - job_id: 42 - - idle_jobs.logs: - source: - fixture: test/fixtures/idle_jobs/logs.json - note: Parameter preset for fetching idle job logs by node and job identifiers. - payload: - node_id: 69 - job_id: 42 diff --git a/lib/octaspace/playground/smoke_runner.rb b/lib/octaspace/playground/smoke_runner.rb deleted file mode 100644 index a80f0d0..0000000 --- a/lib/octaspace/playground/smoke_runner.rb +++ /dev/null @@ -1,204 +0,0 @@ -# frozen_string_literal: true - -require "time" - -module OctaSpace - module Playground - class SmokeRunner - SUITES = [ - { - id: "network.info", - label: "octa_client.network.info", - method: "GET", - path: "/network", - requires_auth: false, - fn: ->(client) { client.network.info } - }, - { - id: "accounts.profile", - label: "octa_client.accounts.profile", - method: "GET", - path: "/accounts", - requires_auth: true, - fn: ->(client) { client.accounts.profile } - }, - { - id: "accounts.balance", - label: "octa_client.accounts.balance", - method: "GET", - path: "/accounts/balance", - requires_auth: true, - fn: ->(client) { client.accounts.balance } - }, - { - id: "apps.list", - label: "octa_client.apps.list", - method: "GET", - path: "/apps", - requires_auth: true, - fn: ->(client) { client.apps.list } - }, - { - id: "nodes.list", - label: "octa_client.nodes.list", - method: "GET", - path: "/nodes", - requires_auth: true, - fn: ->(client) { client.nodes.list } - }, - { - id: "services.mr.list", - label: "octa_client.services.mr.list", - method: "GET", - path: "/services/mr", - requires_auth: true, - fn: ->(client) { client.services.mr.list } - }, - { - id: "services.render.list", - label: "octa_client.services.render.list", - method: "GET", - path: "/services/render", - requires_auth: true, - fn: ->(client) { client.services.render.list } - }, - { - id: "services.vpn.list", - label: "octa_client.services.vpn.list", - method: "GET", - path: "/services/vpn", - requires_auth: true, - fn: ->(client) { client.services.vpn.list } - }, - { - id: "sessions.list", - label: "octa_client.sessions.list", - method: "GET", - path: "/sessions", - requires_auth: true, - fn: ->(client) { client.sessions.list } - }, - { - id: "sessions.list.recent", - label: "octa_client.sessions.list(recent: true)", - method: "GET", - path: "/sessions?recent=true", - requires_auth: true, - fn: ->(client) { client.sessions.list(recent: true) } - } - ].freeze - - def initialize(client:, executor: nil) - @client = client - @executor = executor || method(:default_execute) - end - - def run - started_at = monotonic_now - suites = SUITES.map { |suite| run_suite(suite) } - passed_count = suites.count { |suite| suite[:status] == "passed" } - failed_count = suites.count { |suite| suite[:status] == "failed" } - - { - kind: "sdk_smoke", - status: failed_count.zero? ? "passed" : "failed", - started_at: Time.now.utc.iso8601, - duration_ms: elapsed_ms(started_at), - summary: { - total: suites.length, - passed: passed_count, - failed: failed_count - }, - suites: - } - end - - private - - attr_reader :client, :executor - - def run_suite(suite) - started_at = monotonic_now - response = executor.call(suite) { suite[:fn].call(client) } - - if response.is_a?(OctaSpace::Error) - { - id: suite[:id], - label: suite[:label], - method: suite[:method], - path: suite[:path], - requires_auth: suite[:requires_auth], - status: "failed", - duration_ms: elapsed_ms(started_at), - error: { - class: response.class.name, - message: response.message, - status: response.status, - request_id: response.request_id - }.compact - } - else - { - id: suite[:id], - label: suite[:label], - method: suite[:method], - path: suite[:path], - requires_auth: suite[:requires_auth], - status: "passed", - duration_ms: elapsed_ms(started_at), - http_status: response.status, - request_id: response.request_id, - summary: summarize_payload(response.data) - }.compact - end - rescue => e - { - id: suite[:id], - label: suite[:label], - method: suite[:method], - path: suite[:path], - requires_auth: suite[:requires_auth], - status: "failed", - duration_ms: elapsed_ms(started_at), - error: { - class: e.class.name, - message: e.message - } - } - end - - def summarize_payload(payload) - case payload - when Array - { - type: "array", - count: payload.length, - sample_keys: payload.first.is_a?(Hash) ? payload.first.keys.take(8) : nil - }.compact - when Hash - { - type: "object", - keys: payload.keys.take(12) - } - else - { - type: payload.class.name, - value: payload - } - end - end - - def default_execute(_suite) - yield - end - - def monotonic_now - Process.clock_gettime(Process::CLOCK_MONOTONIC) - end - - def elapsed_ms(started_at) - ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round - end - end - end -end diff --git a/lib/octaspace/transport/mock_transport.rb b/lib/octaspace/transport/mock_transport.rb deleted file mode 100644 index 035af10..0000000 --- a/lib/octaspace/transport/mock_transport.rb +++ /dev/null @@ -1,138 +0,0 @@ -# frozen_string_literal: true - -require "securerandom" - -module OctaSpace - module Transport - # Fixture-backed mock transport used by the playground and tests. - # - # Supports one happy-path fixture mode plus the same error-oriented - # scenarios exposed in the JS playground. - class MockTransport < Base - SCENARIOS = [ - {value: "real", label: "Real API", description: "Use the actual OctaSpace API"}, - {value: "401", label: "401 Unauthorized", description: "Simulates invalid or missing API key"}, - {value: "403", label: "403 Forbidden", description: "Simulates insufficient permissions"}, - {value: "404", label: "404 Not Found", description: "Simulates a missing resource"}, - {value: "429", label: "429 Rate Limited", description: "Simulates rate limiting with Retry-After: 60"}, - {value: "500", label: "500 Server Error", description: "Simulates an internal server error"}, - {value: "slow", label: "Slow (3s)", description: "Adds a 3-second delay before responding with fixtures"}, - {value: "timeout", label: "Timeout", description: "Raises a timeout error without contacting the API"}, - {value: "network-error", label: "Network Error", description: "Simulates a connection failure"} - ].freeze - - DEFAULT_DELAY_SECONDS = 3.0 - RawResponse = Struct.new(:status, :headers, :body, keyword_init: true) - - def self.scenarios = SCENARIOS - - def initialize(config, scenario:, delay_seconds: DEFAULT_DELAY_SECONDS) - @config = config - @scenario = scenario.to_s - @delay_seconds = delay_seconds - end - - attr_reader :scenario - - def get(path, params: {}, headers: {}) - request(:get, path, params: params, headers: headers) - end - - def post(path, body: nil, headers: {}) - request(:post, path, body: body, headers: headers) - end - - def put(path, body: nil, headers: {}) - request(:put, path, body: body, headers: headers) - end - - def patch(path, body: nil, headers: {}) - request(:patch, path, body: body, headers: headers) - end - - def delete(path, params: {}, headers: {}) - request(:delete, path, params: params, headers: headers) - end - - def transport_stats - { - mode: :mock, - scenario: scenario, - label: scenario_metadata[:label] - } - end - - private - - attr_reader :config, :delay_seconds - - def request(method, path, params: {}, body: nil, headers: {}) - invoke_hook(config.on_request, {method: method, path: path, params: params, body: body, headers: headers}) - - simulate_transport_failure! - simulate_delay! if scenario == "slow" - - raise ConfigurationError, "MockTransport cannot be used with the real scenario" if scenario == "real" - - response = build_mock_response - - invoke_hook(config.on_response, response) - raise OctaSpace.error_for(response) if response.error? - - response - end - - def mock_response_scenario? - %w[401 403 404 429 500 slow].include?(scenario) - end - - def simulate_transport_failure! - case scenario - when "network-error" - raise ConnectionError, "Mocked network failure" - when "timeout" - raise TimeoutError, "Mocked timeout after #{config.read_timeout}s" - end - end - - def simulate_delay! - sleep(delay_seconds) - end - - def build_mock_response - status = (scenario == "slow") ? 200 : scenario.to_i - headers = {"content-type" => "application/json"} - headers["retry-after"] = "60" if scenario == "429" - - build_response( - status: status, - headers: headers, - body: {"error" => "Mocked #{status}", "scenario" => scenario} - ) - end - - def build_response(status:, body:, headers: {}) - response_headers = { - "content-type" => "application/json", - "x-request-id" => "mock-#{SecureRandom.hex(6)}" - }.merge(headers) - - Response.new( - RawResponse.new( - status: status, - headers: response_headers, - body: body - ) - ) - end - - def scenario_metadata - self.class.scenarios.find { |entry| entry[:value] == scenario } || self.class.scenarios.first - end - - def invoke_hook(hook, *args) - hook&.call(*args) - end - end - end -end diff --git a/octaspace.gemspec b/octaspace.gemspec index 453b233..fa012a9 100644 --- a/octaspace.gemspec +++ b/octaspace.gemspec @@ -14,7 +14,7 @@ Gem::Specification.new do |spec| spec.homepage = "https://github.com/octaspace/ruby-sdk" spec.license = "MIT" - spec.required_ruby_version = ">= 3.2.0" + spec.required_ruby_version = ">= 3.3.0" spec.metadata = { "changelog_uri" => "#{spec.homepage}/blob/main/CHANGELOG.md", @@ -23,11 +23,10 @@ Gem::Specification.new do |spec| "rubygems_mfa_required" => "true" } - spec.files = Dir["lib/**/*", "MIT-LICENSE", "README.md", "CHANGELOG.md"] - - Dir["lib/octaspace/playground/**/*", "lib/octaspace/transport/mock_transport.rb", "lib/octaspace/engine.rb"] + spec.files = Dir["lib/**/*.rb", "MIT-LICENSE", "README.md", "CHANGELOG.md"] spec.require_paths = ["lib"] - spec.add_dependency "faraday", "~> 2.9" + spec.add_dependency "faraday", ">= 2.14.3", "< 3" spec.add_dependency "faraday-retry", "~> 2.2" # Optional — persistent mode (users add to their own Gemfile): diff --git a/test/dummy/Rakefile b/test/dummy/Rakefile deleted file mode 100644 index 5c0e575..0000000 --- a/test/dummy/Rakefile +++ /dev/null @@ -1,4 +0,0 @@ -# frozen_string_literal: true - -APP_RAKEFILE = __FILE__ -load "rails/tasks/engine.rake" diff --git a/test/dummy/app/controllers/application_controller.rb b/test/dummy/app/controllers/application_controller.rb deleted file mode 100644 index b9495db..0000000 --- a/test/dummy/app/controllers/application_controller.rb +++ /dev/null @@ -1,341 +0,0 @@ -# frozen_string_literal: true - -require "digest" -require "json" -require "octaspace/transport/mock_transport" -require "octaspace/playground/payload_presets" -require "octaspace/playground/smoke_runner" - -class ApplicationController < ActionController::Base - after_action :shutdown_octa_client - - helper_method \ - :playground_diagnostics_preset_path, - :octa_client, - :playground_api_key_present?, - :playground_auth_available?, - :playground_config, - :playground_error_payload, - :playground_format_bytes, - :playground_format_datetime, - :playground_format_duration, - :playground_format_mbps, - :playground_format_short_time, - :playground_json, - :playground_json_html, - :playground_masked_api_key, - :playground_mock_active?, - :playground_mock_scenario, - :playground_mock_scenarios, - :playground_asset_path, - :playground_payload, - :playground_present?, - :playground_query_url, - :playground_timeout_label, - :playground_transport_description, - :playground_transport_label, - :playground_transport_stats, - :playground_truncate - - private - - def octa_client - @octa_client ||= begin - client_options = {api_key: playground_effective_api_key} - client_options[:base_url] = playground_effective_base_url if playground_base_url_override? - client_options[:keep_alive] = false if playground_mock_active? - - if playground_mock_active? - transport = OctaSpace::Transport::MockTransport.new(playground_config, scenario: playground_mock_scenario) - OctaSpace::Client.new(**client_options, transport: transport) - else - OctaSpace::Client.new(**client_options) - end - end - end - - def playground_config - @playground_config ||= begin - config = OctaSpace.configuration.dup - config.api_key = playground_effective_api_key - config.base_url = playground_effective_base_url - config.keep_alive = false if playground_mock_active? - config - end - end - - def playground_transport_stats - @playground_transport_stats ||= octa_client.transport_stats - end - - def playground_api_key_present? - playground_config.api_key.present? - end - - def playground_auth_available? - playground_mock_active? || playground_api_key_present? - end - - def playground_mock_active? - playground_mock_scenario != "real" - end - - def playground_mock_scenario - value = playground_settings["mock_scenario"].presence || "real" - playground_mock_scenarios.any? { |scenario| scenario[:value] == value } ? value : "real" - end - - def playground_mock_scenarios - OctaSpace::Transport::MockTransport.scenarios - end - - def playground_payload(value) - value.respond_to?(:data) ? value.data : value - end - - def playground_diagnostics_preset_path(call_id, overrides = {}) - payload = OctaSpace::Playground::PayloadPresets.payload_for(call_id).merge(compact_present_hash(overrides)) - playground_diagnostics_path(call: call_id, payload_json: JSON.pretty_generate(payload)) - end - - def playground_asset_path(asset_name) - asset_path = Rails.public_path.join(asset_name.to_s.delete_prefix("/")) - return "/#{asset_name}" unless asset_path.file? - - fingerprint = Digest::SHA256.file(asset_path).hexdigest.first(12) - "/#{asset_name}?v=#{fingerprint}" - end - - def playground_present?(value) - !value.nil? - end - - def playground_json(value) - JSON.pretty_generate(playground_serializable(value)) - rescue JSON::GeneratorError - playground_serializable(value).inspect - end - - def playground_json_html(value) - render_json_node(playground_serializable(value), 0) - end - - def playground_error_payload(error) - payload = { - class: error.class.name, - message: error.message, - status: error.status, - request_id: error.request_id - } - - response_data = error.response&.data - payload[:response] = response_data if response_data.present? - payload[:retry_after] = error.retry_after if error.respond_to?(:retry_after) && error.retry_after.present? - payload.compact - end - - def playground_query_url(param, value) - url_for(request.path_parameters.merge(request.query_parameters).merge(param.to_s => value, only_path: true)) - end - - def playground_masked_api_key - key = playground_config.api_key.to_s - return "" if key.empty? - return ("*" * key.length) if key.length <= 8 - - "#{key[0, 4]}#{'*' * [key.length - 8, 4].max}#{key[-4, 4]}" - end - - def playground_transport_label - return playground_selected_scenario[:label] if playground_mock_active? - - playground_config.keep_alive? ? "Real API / Keep-Alive" : "Real API" - end - - def playground_transport_description - return playground_selected_scenario[:description] if playground_mock_active? - - playground_config.keep_alive? ? "Uses the real API through the persistent transport" : "Uses the real API through the standard Faraday transport" - end - - def playground_timeout_label - "#{playground_config.open_timeout}s / #{playground_config.read_timeout}s" - end - - def playground_format_duration(value) - seconds = value.to_i - return "—" if seconds <= 0 - - hours = seconds / 3600 - minutes = (seconds % 3600) / 60 - remainder = seconds % 60 - - return "#{hours}h #{minutes}m" if hours.positive? - return "#{minutes}m #{remainder}s" if minutes.positive? - - "#{remainder}s" - end - - def playground_format_bytes(value) - bytes = value.to_f - return "0 B" unless bytes.positive? - - units = %w[B KB MB GB TB] - index = 0 - - while bytes >= 1024 && index < units.length - 1 - bytes /= 1024.0 - index += 1 - end - - format("%.1f %s", bytes, units[index]) - end - - def playground_format_mbps(value) - normalized = OctaSpace::PayloadHelpers.normalize_marketplace_bandwidth(value) - return "—" if normalized.blank? - - format("%.1f Mbps", normalized) - end - - def playground_format_datetime(value) - return "—" if value.blank? - - timestamp = - case value - when Time, DateTime, ActiveSupport::TimeWithZone - value - when Numeric - numeric = value.to_f - numeric > 10_000_000_000 ? Time.zone.at(numeric / 1000.0) : Time.zone.at(numeric) - else - Time.zone.parse(value.to_s) - end - - timestamp&.strftime("%b %-d, %H:%M") || "—" - rescue ArgumentError, TypeError - value.to_s - end - - def playground_format_short_time(value) - return "—" if value.blank? - - timestamp = - case value - when Time, DateTime, ActiveSupport::TimeWithZone - value - when Numeric - value.to_f > 10_000_000_000 ? Time.zone.at(value.to_f / 1000.0) : Time.zone.at(value.to_f) - else - Time.zone.parse(value.to_s) - end - - timestamp&.strftime("%H:%M:%S") || value.to_s - rescue ArgumentError, TypeError - value.to_s - end - - def playground_truncate(value, leading: 8, trailing: 5) - string = value.to_s - return string if string.length <= (leading + trailing + 1) - - "#{string[0, leading]}…#{string[-trailing, trailing]}" - end - - def playground_serializable(value) - serializable = playground_payload(value) - - case serializable - when OctaSpace::Error - playground_error_payload(serializable) - when Hash, Array, String, Numeric, TrueClass, FalseClass, NilClass - serializable - when Symbol - serializable.to_s - else - serializable.respond_to?(:to_h) ? serializable.to_h : serializable.to_s - end - end - - def compact_present_hash(hash) - hash.each_with_object({}) do |(key, value), result| - result[key] = value if value.present? - end - end - - def render_json_node(node, level) - case node - when Hash - render_json_hash(node, level) - when Array - render_json_array(node, level) - when String - %(#{ERB::Util.html_escape(node.to_json)}).html_safe - when Numeric - %(#{node}).html_safe - when TrueClass, FalseClass - %(#{node}).html_safe - when NilClass - %(null).html_safe - else - %(#{ERB::Util.html_escape(node.to_s.to_json)}).html_safe - end - end - - def render_json_hash(hash, level) - return "{}".html_safe if hash.empty? - - lines = hash.map do |key, value| - %(#{json_indent(level + 1)}#{ERB::Util.html_escape(key.to_s.to_json)}: #{render_json_node(value, level + 1)}) - end - - "{\n#{lines.join(",\n")}\n#{json_indent(level)}}".html_safe - end - - def render_json_array(array, level) - return "[]".html_safe if array.empty? - - lines = array.map do |value| - "#{json_indent(level + 1)}#{render_json_node(value, level + 1)}" - end - - "[\n#{lines.join(",\n")}\n#{json_indent(level)}]".html_safe - end - - def json_indent(level) - " " * level - end - - def shutdown_octa_client - @octa_client&.shutdown - @octa_client = nil - end - - def playground_settings - session[:playground_settings] ||= {} - end - - def playground_selected_scenario - playground_mock_scenarios.find { |scenario| scenario[:value] == playground_mock_scenario } || playground_mock_scenarios.first - end - - def playground_effective_api_key - if playground_settings.key?("api_key") - playground_settings["api_key"].to_s - else - OctaSpace.configuration.api_key.to_s - end - end - - def playground_effective_base_url - if playground_base_url_override? - playground_settings["base_url"].to_s - else - OctaSpace.configuration.base_url.to_s - end - end - - def playground_base_url_override? - playground_settings["base_url"].present? - end -end diff --git a/test/dummy/app/controllers/playground/accounts_controller.rb b/test/dummy/app/controllers/playground/accounts_controller.rb deleted file mode 100644 index 537fbec..0000000 --- a/test/dummy/app/controllers/playground/accounts_controller.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -module Playground - class AccountsController < BaseController - def show - if playground_api_key_present? - @profile = capture_api_call(label: "octa_client.accounts.profile", path: "/accounts") { octa_client.accounts.profile } - @balance = capture_api_call(label: "octa_client.accounts.balance", path: "/accounts/balance") { octa_client.accounts.balance } - else - @profile = nil - @balance = nil - end - end - end -end diff --git a/test/dummy/app/controllers/playground/apps_controller.rb b/test/dummy/app/controllers/playground/apps_controller.rb deleted file mode 100644 index d74f994..0000000 --- a/test/dummy/app/controllers/playground/apps_controller.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -module Playground - class AppsController < BaseController - def show - @apps = capture_api_call(label: "octa_client.apps.list", path: "/apps") { octa_client.apps.list } - end - end -end diff --git a/test/dummy/app/controllers/playground/base_controller.rb b/test/dummy/app/controllers/playground/base_controller.rb deleted file mode 100644 index a9ce030..0000000 --- a/test/dummy/app/controllers/playground/base_controller.rb +++ /dev/null @@ -1,78 +0,0 @@ -# frozen_string_literal: true - -require "securerandom" - -module Playground - class BaseController < ApplicationController - before_action :initialize_request_log - - private - - def initialize_request_log - session.delete(:playground_request_log) - @request_log_key = playground_request_log_key - @request_log = Array(playground_request_log_store[@request_log_key]) - end - - def capture_api_call(label: nil, method: "GET", path: nil) - started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) - response = yield - log_request( - label:, - method:, - path: path, - status: response.status, - duration_ms: duration_ms(started_at) - ) - response - rescue OctaSpace::Error => e - log_request( - label:, - method:, - path: path, - status: e.status || "ERR", - duration_ms: duration_ms(started_at), - error: e.message - ) - e - end - - def duration_ms(started_at) - ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round - end - - def log_request(label:, method:, path:, status:, duration_ms:, error: nil) - @request_log << { - "label" => label, - "method" => method, - "path" => path, - "status" => status, - "duration_ms" => duration_ms, - "timestamp" => Time.current.iso8601, - "error" => error - } - - playground_request_log_mutex.synchronize do - playground_request_log_store[@request_log_key] = @request_log.last(100) - end - end - - def clear_request_log! - playground_request_log_mutex.synchronize do - playground_request_log_store.delete(@request_log_key) - end - end - - def playground_request_log_key - session[:playground_request_log_token] ||= SecureRandom.hex(12) - end - - def playground_request_log_store - Rails.application.config.x.playground_request_logs ||= {} - end - - def playground_request_log_mutex - Rails.application.config.x.playground_request_log_mutex ||= Mutex.new - end - end -end diff --git a/test/dummy/app/controllers/playground/dashboard_controller.rb b/test/dummy/app/controllers/playground/dashboard_controller.rb deleted file mode 100644 index 56eb202..0000000 --- a/test/dummy/app/controllers/playground/dashboard_controller.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -module Playground - class DashboardController < BaseController - def show - @network = capture_api_call(label: "octa_client.network.info", path: "/network") { octa_client.network.info } - - if playground_api_key_present? - @profile = capture_api_call(label: "octa_client.accounts.profile", path: "/accounts") { octa_client.accounts.profile } - @balance = capture_api_call(label: "octa_client.accounts.balance", path: "/accounts/balance") { octa_client.accounts.balance } - else - @profile = nil - @balance = nil - end - end - end -end diff --git a/test/dummy/app/controllers/playground/diagnostics_controller.rb b/test/dummy/app/controllers/playground/diagnostics_controller.rb deleted file mode 100644 index 31185e0..0000000 --- a/test/dummy/app/controllers/playground/diagnostics_controller.rb +++ /dev/null @@ -1,329 +0,0 @@ -# frozen_string_literal: true - -module Playground - class DiagnosticsController < BaseController - SDK_CALLS = [ - { - id: "network.info", - label: "octa_client.network.info", - description: "GET /network — public, no auth required", - method: "GET", - path: "/network", - requires_auth: false - }, - { - id: "accounts.profile", - label: "octa_client.accounts.profile", - description: "GET /accounts — requires API key", - method: "GET", - path: "/accounts", - requires_auth: true - }, - { - id: "accounts.balance", - label: "octa_client.accounts.balance", - description: "GET /accounts/balance — requires API key", - method: "GET", - path: "/accounts/balance", - requires_auth: true - }, - { - id: "apps.list", - label: "octa_client.apps.list", - description: "GET /apps — requires API key; live API may serialize ports/http_ports as JSON strings", - method: "GET", - path: "/apps", - requires_auth: true - }, - { - id: "idle_jobs.find", - label: "octa_client.idle_jobs.find(node_id:, job_id:)", - description: "GET /idle_jobs/:node_id/:job_id — requires API key", - method: "GET", - path: "/idle_jobs/:node_id/:job_id", - requires_auth: true, - payload: true - }, - { - id: "idle_jobs.logs", - label: "octa_client.idle_jobs.logs(node_id:, job_id:)", - description: "GET /idle_jobs/:node_id/:job_id/logs — requires API key", - method: "GET", - path: "/idle_jobs/:node_id/:job_id/logs", - requires_auth: true, - payload: true - }, - { - id: "nodes.list", - label: "octa_client.nodes.list", - description: "GET /nodes — requires API key", - method: "GET", - path: "/nodes", - requires_auth: true - }, - { - id: "sessions.list", - label: "octa_client.sessions.list", - description: "GET /sessions — requires API key", - method: "GET", - path: "/sessions", - requires_auth: true - }, - { - id: "sessions.list.recent", - label: "octa_client.sessions.list(recent: true)", - description: "GET /sessions?recent=true — requires API key; live telemetry fields may be serialized as strings", - method: "GET", - path: "/sessions?recent=true", - requires_auth: true - }, - { - id: "services.session.logs", - label: "octa_client.services.session(uuid).logs", - description: "GET /services/:uuid/logs — for finished sessions use recent=true", - method: "GET", - path: "/services/:uuid/logs", - requires_auth: true, - payload: true - }, - { - id: "services.mr.list", - label: "octa_client.services.mr.list", - description: "GET /services/mr — requires API key; returns marketplace machine catalog", - method: "GET", - path: "/services/mr", - requires_auth: true - }, - { - id: "services.render.list", - label: "octa_client.services.render.list", - description: "GET /services/render — requires API key; returns render marketplace catalog", - method: "GET", - path: "/services/render", - requires_auth: true - }, - { - id: "services.vpn.list", - label: "octa_client.services.vpn.list", - description: "GET /services/vpn — requires API key; returns VPN relay catalog", - method: "GET", - path: "/services/vpn", - requires_auth: true - }, - { - id: "services.mr.create", - label: "octa_client.services.mr.create", - description: "POST /services/mr — requires API key; live API uses array payloads and may reject item-level with HTTP 200", - method: "POST", - path: "/services/mr", - requires_auth: true, - mutation: true - }, - { - id: "services.render.create", - label: "octa_client.services.render.create", - description: "POST /services/render — requires API key", - method: "POST", - path: "/services/render", - requires_auth: true, - mutation: true - }, - { - id: "services.vpn.create", - label: "octa_client.services.vpn.create", - description: "POST /services/vpn — requires API key", - method: "POST", - path: "/services/vpn", - requires_auth: true, - mutation: true - }, - { - id: "services.session.stop", - label: "octa_client.services.session(uuid).stop", - description: "POST /services/:uuid/stop — requires API key", - method: "POST", - path: "/services/:uuid/stop", - requires_auth: true, - mutation: true - } - ].freeze - - def show - prepare_state - @payload_json = - if call_uses_payload?(@selected_call) - params[:payload_json].presence || default_payload_json(@selected_call) - else - "" - end - end - - def run - prepare_state - @selected_call = find_call(params[:call]) - @selected_call_id = @selected_call[:id] - @payload_json = params[:payload_json].to_s - @payload_json = default_payload_json(@selected_call) if @payload_json.empty? && call_uses_payload?(@selected_call) - - started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @result = execute_call(@selected_call) - @result[:duration_ms] = duration_ms(started_at) - render :show - end - - def smoke - prepare_state - @payload_json = default_payload_json(@selected_call) - runner = OctaSpace::Playground::SmokeRunner.new(client: octa_client, executor: method(:execute_smoke_suite)) - smoke_result = runner.run - @result = { - status: :smoke, - duration_ms: smoke_result[:duration_ms], - data: smoke_result - } - render :show - rescue StandardError => e - @result = {status: :error, error: wrap_smoke_error(e)} - render :show - end - - private - - def prepare_state - @config = playground_config - @transport_stats = playground_transport_stats - @rotator_stats = extract_rotator_stats - @calls = SDK_CALLS - @selected_call_id = params[:call].presence || @calls.first[:id] - @selected_call = find_call(@selected_call_id) - @result = {status: :idle} - end - - def find_call(call_id) - @calls.find { |call| call[:id] == call_id } || @calls.first - end - - def execute_call(call) - payload = call_uses_payload?(call) ? parse_payload!(call, @payload_json) : nil - path = resolved_path(call, payload) - - response = capture_api_call(label: call[:label], method: call[:method], path:) do - case call[:id] - when "network.info" - octa_client.network.info - when "accounts.profile" - octa_client.accounts.profile - when "accounts.balance" - octa_client.accounts.balance - when "apps.list" - octa_client.apps.list - when "idle_jobs.find" - octa_client.idle_jobs.find(node_id: payload.fetch(:node_id), job_id: payload.fetch(:job_id)) - when "idle_jobs.logs" - octa_client.idle_jobs.logs(node_id: payload.fetch(:node_id), job_id: payload.fetch(:job_id)) - when "nodes.list" - octa_client.nodes.list - when "sessions.list" - octa_client.sessions.list - when "sessions.list.recent" - octa_client.sessions.list(recent: true) - when "services.session.logs" - uuid = payload.fetch(:uuid).to_s - octa_client.services.session(uuid).logs(recent: payload[:recent]) - when "services.mr.list" - octa_client.services.mr.list - when "services.render.list" - octa_client.services.render.list - when "services.vpn.list" - octa_client.services.vpn.list - when "services.mr.create" - octa_client.services.mr.create(**payload) - when "services.render.create" - octa_client.services.render.create(**payload) - when "services.vpn.create" - octa_client.services.vpn.create(**payload) - when "services.session.stop" - uuid = payload.fetch(:uuid).to_s - octa_client.services.session(uuid).stop(**payload.except(:uuid)) - end - end - - return {status: :error, error: response} if response.is_a?(OctaSpace::Error) - - {status: :success, data: response.data, response: response} - rescue OctaSpace::Error => e - {status: :error, error: e} - end - - def duration_ms(started_at) - ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round - end - - def execute_smoke_suite(suite) - capture_api_call(label: suite[:label], method: suite[:method], path: suite[:path]) { yield } - end - - def default_payload_json(call) - return "" unless call_uses_payload?(call) - - OctaSpace::Playground::PayloadPresets.payload_json_for(call[:id]) - end - - def parse_payload!(call, payload_json) - if payload_json.blank? - raise OctaSpace::ValidationError.new("Payload JSON is required for #{call[:label]}") - end - - payload = JSON.parse(payload_json) - raise OctaSpace::ValidationError.new("Payload JSON must be an object") unless payload.is_a?(Hash) - - payload = payload.deep_symbolize_keys - validate_mutation_payload!(call, payload) - payload - rescue JSON::ParserError => e - raise OctaSpace::ValidationError.new("Invalid payload JSON: #{e.message}") - end - - def validate_mutation_payload!(call, payload) - case call[:id] - when "services.session.logs", "services.session.stop" - raise OctaSpace::ValidationError.new("Payload must include a non-empty uuid") if payload[:uuid].to_s.strip.empty? - when "idle_jobs.find", "idle_jobs.logs" - raise OctaSpace::ValidationError.new("Payload must include node_id") if payload[:node_id].to_s.strip.empty? - raise OctaSpace::ValidationError.new("Payload must include job_id") if payload[:job_id].to_s.strip.empty? - end - end - - def resolved_path(call, payload) - case call[:id] - when "services.session.logs" - path = "/services/#{payload.fetch(:uuid)}/logs" - payload[:recent] ? "#{path}?recent=true" : path - when "services.session.stop" - "/services/#{payload.fetch(:uuid)}/stop" - when "idle_jobs.find" - "/idle_jobs/#{payload.fetch(:node_id)}/#{payload.fetch(:job_id)}" - when "idle_jobs.logs" - "/idle_jobs/#{payload.fetch(:node_id)}/#{payload.fetch(:job_id)}/logs" - else - call[:path] - end - end - - def call_uses_payload?(call) - call[:mutation] || call[:payload] - end - - def wrap_smoke_error(error) - return error if error.is_a?(OctaSpace::Error) - - OctaSpace::Error.new("Smoke runner failed: #{error.message}") - end - - def extract_rotator_stats - transport = octa_client.instance_variable_get(:@transport) - rotator = transport.instance_variable_get(:@rotator) - rotator&.stats - end - end -end diff --git a/test/dummy/app/controllers/playground/idle_jobs_controller.rb b/test/dummy/app/controllers/playground/idle_jobs_controller.rb deleted file mode 100644 index bfb447c..0000000 --- a/test/dummy/app/controllers/playground/idle_jobs_controller.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -module Playground - class IdleJobsController < BaseController - def show - @node_id = params[:node_id].presence || default_payload[:node_id].to_s - @job_id = params[:job_id].presence || default_payload[:job_id].to_s - - return if params[:node_id].blank? || params[:job_id].blank? - - @job = capture_api_call( - label: "octa_client.idle_jobs.find(node_id: #{@node_id}, job_id: #{@job_id})", - path: "/idle_jobs/#{@node_id}/#{@job_id}" - ) do - octa_client.idle_jobs.find(node_id: @node_id, job_id: @job_id) - end - - @logs = capture_api_call( - label: "octa_client.idle_jobs.logs(node_id: #{@node_id}, job_id: #{@job_id})", - path: "/idle_jobs/#{@node_id}/#{@job_id}/logs" - ) do - octa_client.idle_jobs.logs(node_id: @node_id, job_id: @job_id) - end - end - - private - - def default_payload - OctaSpace::Playground::PayloadPresets.payload_for("idle_jobs.find") - end - end -end diff --git a/test/dummy/app/controllers/playground/network_controller.rb b/test/dummy/app/controllers/playground/network_controller.rb deleted file mode 100644 index 23a5fb5..0000000 --- a/test/dummy/app/controllers/playground/network_controller.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -module Playground - class NetworkController < BaseController - def show - @network = capture_api_call(label: "octa_client.network.info", path: "/network") { octa_client.network.info } - end - end -end diff --git a/test/dummy/app/controllers/playground/nodes_controller.rb b/test/dummy/app/controllers/playground/nodes_controller.rb deleted file mode 100644 index 6fd2b46..0000000 --- a/test/dummy/app/controllers/playground/nodes_controller.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module Playground - class NodesController < BaseController - def index - @nodes = capture_api_call(label: "octa_client.nodes.list", path: "/nodes") { octa_client.nodes.list } - @selected_node_id = params[:selected_id].presence || params[:id].presence - - return if @selected_node_id.blank? - - @selected_node = capture_api_call(label: "octa_client.nodes.find(#{@selected_node_id})", path: "/nodes/#{@selected_node_id}") do - octa_client.nodes.find(@selected_node_id) - end - end - - def show - @node = capture_api_call(label: "octa_client.nodes.find(#{params[:id]})", path: "/nodes/#{params[:id]}") do - octa_client.nodes.find(params[:id]) - end - end - end -end diff --git a/test/dummy/app/controllers/playground/request_logs_controller.rb b/test/dummy/app/controllers/playground/request_logs_controller.rb deleted file mode 100644 index 6da33f6..0000000 --- a/test/dummy/app/controllers/playground/request_logs_controller.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -module Playground - class RequestLogsController < BaseController - def destroy - clear_request_log! - redirect_back fallback_location: playground_dashboard_path - end - end -end diff --git a/test/dummy/app/controllers/playground/services_controller.rb b/test/dummy/app/controllers/playground/services_controller.rb deleted file mode 100644 index f324800..0000000 --- a/test/dummy/app/controllers/playground/services_controller.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -module Playground - class ServicesController < BaseController - def show - @active_tab = params[:tab].presence_in(%w[mr render vpn]) || "mr" - @mr_catalog = capture_api_call(label: "octa_client.services.mr.list", path: "/services/mr") { octa_client.services.mr.list } - @render_catalog = capture_api_call(label: "octa_client.services.render.list", path: "/services/render") { octa_client.services.render.list } - @vpn_catalog = capture_api_call(label: "octa_client.services.vpn.list", path: "/services/vpn") { octa_client.services.vpn.list } - end - end -end diff --git a/test/dummy/app/controllers/playground/sessions_controller.rb b/test/dummy/app/controllers/playground/sessions_controller.rb deleted file mode 100644 index bb116c7..0000000 --- a/test/dummy/app/controllers/playground/sessions_controller.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -module Playground - class SessionsController < BaseController - def index - @active_tab = params[:tab].presence_in(%w[current recent]) || "current" - load_sessions - @stop_uuid = params[:stop_uuid].presence - end - - def stop - @active_tab = params[:tab].presence_in(%w[current recent]) || "current" - uuid = params[:uuid].to_s.strip - - if uuid.blank? - flash[:playground_stop_error] = "Session UUID is required" - redirect_to playground_sessions_path(tab: @active_tab), status: :see_other - return - end - - stop_params = {} - stop_params[:score] = params[:score].to_i if params[:score].present? - - response = capture_api_call( - label: "octa_client.services.session(#{uuid}).stop", - method: "POST", - path: "/services/#{uuid}/stop" - ) do - octa_client.services.session(uuid).stop(**stop_params) - end - - if response.is_a?(OctaSpace::Error) - flash[:playground_stop_error] = response.message - else - flash[:playground_stop_notice] = { - "message" => "Session #{uuid} stop request submitted.", - "request_id" => response.request_id - } - end - - redirect_to playground_sessions_path(tab: @active_tab), status: :see_other - end - - private - - def load_sessions - @current_sessions = capture_api_call(label: "octa_client.sessions.list", path: "/sessions") { octa_client.sessions.list } - @recent_sessions = capture_api_call(label: "octa_client.sessions.list(recent: true)", path: "/sessions?recent=true") do - octa_client.sessions.list(recent: true) - end - end - end -end diff --git a/test/dummy/app/controllers/playground/settings_controller.rb b/test/dummy/app/controllers/playground/settings_controller.rb deleted file mode 100644 index e11763f..0000000 --- a/test/dummy/app/controllers/playground/settings_controller.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -module Playground - class SettingsController < ApplicationController - def update - settings = session[:playground_settings] ||= {} - - settings["api_key"] = params[:api_key].to_s if params.key?(:api_key) - - if params.key?(:base_url) - base_url = params[:base_url].to_s.strip - base_url.present? ? settings["base_url"] = base_url : settings.delete("base_url") - end - - if params.key?(:mock_scenario) - scenario = params[:mock_scenario].to_s - settings["mock_scenario"] = valid_scenario?(scenario) ? scenario : "real" - end - - redirect_to safe_return_path - end - - def destroy - session.delete(:playground_settings) - redirect_to safe_return_path - end - - private - - def valid_scenario?(value) - OctaSpace::Transport::MockTransport.scenarios.any? { |scenario| scenario[:value] == value } - end - - def safe_return_path - path = params[:return_to].to_s - return playground_dashboard_path unless path.start_with?("/") && !path.start_with?("//") - - path - end - end -end diff --git a/test/dummy/app/views/layouts/application.html.erb b/test/dummy/app/views/layouts/application.html.erb deleted file mode 100644 index 62eb156..0000000 --- a/test/dummy/app/views/layouts/application.html.erb +++ /dev/null @@ -1,180 +0,0 @@ - - - - OctaSpace SDK Playground - - <%= csrf_meta_tags %> - "> - - - - -
- - -
-
- -
- <%= yield %> -
-
- -
-
-
-
-

Request Log

-
<%= @request_log&.length || 0 %>
-
- <% if @request_log.present? %> - <%= button_to "Clear", playground_request_log_path, method: :delete, form: { class: "inline" }, class: "text-xs text-slate-500 transition-colors hover:text-slate-300" %> - <% end %> -
- - <% if @request_log.present? %> -
- - - - - - - - - - - - <% @request_log.reverse_each do |entry| %> - - - - - - - - <% end %> - -
MethodPathStatusDurationTime
<%= entry["method"] %>"><%= entry["path"] || entry["label"] || "—" %> - <% if entry["error"].present? %> - ">ERR - <% else %> - "><%= entry["status"] %> - <% end %> - <%= entry["duration_ms"] %>ms<%= playground_format_short_time(entry["timestamp"]) %>
-
- <% else %> -
No requests yet
- <% end %> -
-
-
-
- - diff --git a/test/dummy/app/views/playground/accounts/show.html.erb b/test/dummy/app/views/playground/accounts/show.html.erb deleted file mode 100644 index b9b4a23..0000000 --- a/test/dummy/app/views/playground/accounts/show.html.erb +++ /dev/null @@ -1,66 +0,0 @@ -<% profile_data = @profile.respond_to?(:data) ? @profile.data : nil %> -<% balance_data = @balance.respond_to?(:data) ? @balance.data : nil %> -<% profile_error = @profile if @profile.is_a?(OctaSpace::Error) %> -<% balance_error = @balance if @balance.is_a?(OctaSpace::Error) %> -<% profile_hash = profile_data || {} %> -<% balance_hash = balance_data || {} %> -<% balance_value = balance_data && (balance_hash["balance"] || balance_hash["amount"] || balance_hash.values.first) %> -<% profile_formatted = capture do %> -
-
- Account UUID - <%= profile_hash["account_uuid"] || profile_hash.dig("account", "uuid") || "—" %> -
-
- Email - <%= profile_hash["email"] || profile_hash.dig("account", "email") || "—" %> -
-
- Avatar - <%= profile_hash["avatar"] || "—" %> -
-
-<% end %> -<% balance_formatted = capture do %> -
-
-
Balance
-
<%= balance_value || "—" %>
-
-
-
Currency
-
<%= balance_hash["currency"] || balance_hash["unit"] || "OCTA" %>
-
-
-<% end %> - -
-
-

Account

-

- Direct account view for the Ruby playground. The Dashboard uses the same panels in a condensed layout. -

-
- -
- <%= render "playground/shared/resource_panel", - title: "Profile", - hook: "octa_client.accounts.profile", - data: profile_data, - error: profile_error, - locked: !playground_api_key_present?, - tab_param: :profile_tab, - locked_copy: "Enter an API key in the sidebar to load this resource", - formatted: profile_formatted %> - - <%= render "playground/shared/resource_panel", - title: "Balance", - hook: "octa_client.accounts.balance", - data: balance_data, - error: balance_error, - locked: !playground_api_key_present?, - tab_param: :balance_tab, - locked_copy: "Enter an API key in the sidebar to load this resource", - formatted: balance_formatted %> -
-
diff --git a/test/dummy/app/views/playground/apps/show.html.erb b/test/dummy/app/views/playground/apps/show.html.erb deleted file mode 100644 index 1b1a496..0000000 --- a/test/dummy/app/views/playground/apps/show.html.erb +++ /dev/null @@ -1,95 +0,0 @@ -<% apps_data = @apps.respond_to?(:data) ? Array(@apps.data) : [] %> -<% apps_error = @apps if @apps.is_a?(OctaSpace::Error) %> - -
-
-
-

Apps

-

- octa_client.apps.list — available application catalog for service sessions. -

-

- Port counts below normalize live payloads even when the API serializes ports and http_ports as JSON strings. -

-
-
- <% if apps_data.any? %> - - <% end %> - <% if playground_mock_active? %> - Mock: <%= playground_mock_scenario %> - <% end %> - <%= link_to "Refetch", playground_apps_path(request.query_parameters), class: "rounded border border-slate-700 px-3 py-1.5 text-xs text-slate-400 transition-colors hover:border-slate-600 hover:text-slate-200" %> -
-
- - <% if apps_error %> - <%= render "playground/shared/error_card", error: apps_error %> - <% elsif apps_data.any? %> -
-
- <%= apps_data.length %> apps - octa_client.apps.list -
-
- - - - - - - - - - - - - <% apps_data.each do |app| %> - <% app_uuid = app["uuid"].presence %> - <% app_image = app["image"].presence || app["docker_image"].presence %> - - - - - - - - - <% end %> - -
NameCategoryUUIDImagePortsActions
-
<%= app["name"] || "—" %>
- <% if app["description"].present? %> -
<%= app["description"] %>
- <% end %> -
<%= app["category"] || "—" %>"><%= app["uuid"].presence || "—" %><%= app["image"] || app["docker_image"] || "—" %> - <%= [ - OctaSpace::PayloadHelpers.parse_port_list(app["ports"]).length, - OctaSpace::PayloadHelpers.parse_port_list(app["http_ports"]).length - ].sum %> - - <% if app_uuid.present? %> - <%= link_to "Use in MR Create", - playground_diagnostics_preset_path("services.mr.create", app: app_uuid, image: app_image), - class: "inline-flex items-center rounded border border-cyan-500/20 bg-cyan-500/10 px-2.5 py-1 text-[11px] font-medium text-cyan-400 transition-colors hover:bg-cyan-500/20 hover:text-cyan-300" %> - <% else %> - No UUID - <% end %> -
-
-
- -
-
Raw JSON
- <%= render "playground/shared/json_viewer", data: apps_data, max_height: "max-h-80" %> -
- <% else %> -
No apps found.
- <% end %> -
diff --git a/test/dummy/app/views/playground/dashboard/show.html.erb b/test/dummy/app/views/playground/dashboard/show.html.erb deleted file mode 100644 index 9b7de28..0000000 --- a/test/dummy/app/views/playground/dashboard/show.html.erb +++ /dev/null @@ -1,111 +0,0 @@ -<% network_data = @network.respond_to?(:data) ? @network.data : nil %> -<% profile_data = @profile.respond_to?(:data) ? @profile.data : nil %> -<% balance_data = @balance.respond_to?(:data) ? @balance.data : nil %> -<% network_error = @network if @network.is_a?(OctaSpace::Error) %> -<% profile_error = @profile if @profile.is_a?(OctaSpace::Error) %> -<% balance_error = @balance if @balance.is_a?(OctaSpace::Error) %> -<% profile_hash = profile_data || {} %> -<% balance_hash = balance_data || {} %> -<% balance_value = balance_data && (balance_hash["balance"] || balance_hash["amount"] || balance_hash.values.first) %> -<% account_formatted = capture do %> -
-
- Account UUID - <%= profile_hash["account_uuid"] || profile_hash.dig("account", "uuid") || "—" %> -
-
- Email - <%= profile_hash["email"] || profile_hash.dig("account", "email") || "—" %> -
-
- Avatar - <%= profile_hash["avatar"] || "—" %> -
-
-<% end %> -<% balance_formatted = capture do %> -
-
-
Balance
-
<%= balance_value || "—" %>
-
-
-
Currency
-
<%= balance_hash["currency"] || balance_hash["unit"] || "OCTA" %>
-
-
-<% end %> - -
-
-

Dashboard

-

- Account health check. Demonstrates octa_client resource calls in the Rails dummy app with runtime real/mock transport switching. -

-
- -
- - - <% if playground_mock_active? %> - Mock transport active — - <%= playground_transport_label %> - <% elsif playground_api_key_present? %> - API key set — - <%= playground_config.urls.first %> - <% else %> - No API key — enter one in the sidebar to test authenticated endpoints - <% end %> - - <% if playground_mock_active? %> - Mock: <%= playground_mock_scenario %> - <% elsif playground_config.keep_alive? %> - Keep-Alive - <% end %> -
- -
-
-
-
-

Network Stats

- public -
- octa_client.network.info -
- <%= link_to "Open", playground_network_path, class: "rounded border border-slate-700 px-2.5 py-1 text-xs text-slate-400 transition-colors hover:border-slate-600 hover:text-slate-200" %> -
- - <% if network_error %> - <%= render "playground/shared/error_card", error: network_error, max_height: "max-h-40" %> - <% elsif network_data %> - <%= render "playground/shared/json_viewer", data: network_data, max_height: "max-h-40" %> - <% else %> -
No network payload available.
- <% end %> -
- -
- <%= render "playground/shared/resource_panel", - title: "Account", - hook: "octa_client.accounts.profile", - data: profile_data, - error: profile_error, - locked: !playground_api_key_present?, - tab_param: :account_tab, - empty_copy: "Account data is unavailable.", - locked_copy: "Enter an API key in the sidebar to load this resource", - formatted: account_formatted %> - - <%= render "playground/shared/resource_panel", - title: "Balance", - hook: "octa_client.accounts.balance", - data: balance_data, - error: balance_error, - locked: !playground_api_key_present?, - tab_param: :balance_tab, - empty_copy: "Balance data is unavailable.", - locked_copy: "Enter an API key in the sidebar to load this resource", - formatted: balance_formatted %> -
-
diff --git a/test/dummy/app/views/playground/diagnostics/show.html.erb b/test/dummy/app/views/playground/diagnostics/show.html.erb deleted file mode 100644 index 4169332..0000000 --- a/test/dummy/app/views/playground/diagnostics/show.html.erb +++ /dev/null @@ -1,137 +0,0 @@ -
-
-
-

Diagnostics

-

- Trigger any SDK method directly and inspect the result or error. Switch the transport in the sidebar to test mock scenarios and typed failures. -

-
- <%= form_with url: playground_diagnostics_smoke_path, method: :post, local: true do %> - <%= hidden_field_tag :call, @selected_call_id %> - <%= button_tag "Run SDK Smoke", type: "submit", class: "rounded border border-emerald-500/20 bg-emerald-500/10 px-3 py-1.5 text-xs font-medium text-emerald-400 transition-colors hover:bg-emerald-500/20 hover:text-emerald-300" %> - <% end %> -
- -
-
-
-
<%= playground_transport_label %>
-
- <%= playground_transport_description %>. Open timeout <%= @config.open_timeout %>s, read timeout <%= @config.read_timeout %>s, retries <%= @config.max_retries %>. -
-
- <% if !playground_mock_active? && !playground_api_key_present? %> -
No API key — authenticated calls will fail until one is set
- <% end %> -
- -
-
-

SDK Method

-
- <% @calls.each do |call| %> - <% selected = call[:id] == @selected_call_id %> -
- <%= link_to(playground_diagnostics_path(call: call[:id]), class: "block") do %> -
-
-
<%= call[:label] %>
-
<%= call[:description] %>
-
- <% if call[:mutation] %> - mutation - <% end %> - <% if call[:requires_auth] %> - auth - <% end %> -
- <% end %> - - <% if selected %> -
- <%= form_with url: playground_diagnostics_run_path, method: :post, local: true do %> - <%= hidden_field_tag :call, call[:id] %> - <% if call[:mutation] || call[:payload] %> -
- - <%= text_area_tag :payload_json, - @payload_json, - rows: 8, - spellcheck: false, - class: "w-full rounded-lg border border-slate-800 bg-slate-950/80 px-3 py-2 font-mono text-[11px] leading-5 text-slate-200 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500" %> -

Top-level JSON keys are forwarded as Ruby keyword arguments. For services.session.stop and services.session.logs, include uuid. For services.session.logs, set recent: true when checking finished sessions. For services.mr.create, optional organization_id and project_id are forwarded. For idle_jobs.*, include node_id and job_id.

-
- <% end %> - <%= button_tag "Run Method", type: "submit", class: "w-full rounded border border-cyan-500/20 bg-cyan-500/10 py-1.5 text-xs font-medium text-cyan-400 transition-colors hover:bg-cyan-500/20 hover:text-cyan-300" %> - <% end %> -
- <% end %> -
- <% end %> -
-
- -
-
-

Result

- <% if @result[:duration_ms] %> - <%= @result[:duration_ms] %>ms - <% end %> -
- - <% case @result[:status] %> - <% when :idle %> -

Press Run to execute the selected method.

- <% when :smoke %> - <% smoke = @result[:data] %> -
-
- - - SDK Smoke <%= smoke[:status] == "passed" ? "passed" : "finished with failures" %> - -
-
-
-
Total
-
<%= smoke.dig(:summary, :total) %>
-
-
-
Passed
-
<%= smoke.dig(:summary, :passed) %>
-
-
-
Failed
-
<%= smoke.dig(:summary, :failed) %>
-
-
- <%= render "playground/shared/json_viewer", data: smoke %> -
- <% when :success %> -
-
- - Success -
-
-
-
Status
-
<%= @result[:response].status %>
-
- <% if @result[:response].request_id.present? %> -
-
Request ID
-
<%= @result[:response].request_id %>
-
- <% end %> -
- <%= render "playground/shared/json_viewer", data: @result[:data] %> -
- <% when :error %> - <%= render "playground/shared/error_card", error: @result[:error] %> - <% end %> -
-
-
diff --git a/test/dummy/app/views/playground/idle_jobs/show.html.erb b/test/dummy/app/views/playground/idle_jobs/show.html.erb deleted file mode 100644 index fc9030d..0000000 --- a/test/dummy/app/views/playground/idle_jobs/show.html.erb +++ /dev/null @@ -1,76 +0,0 @@ -<% job_error = @job if @job.is_a?(OctaSpace::Error) %> -<% logs_error = @logs if @logs.is_a?(OctaSpace::Error) %> -<% job_data = @job.respond_to?(:data) ? @job.data : nil %> -<% logs_data = @logs.respond_to?(:data) ? @logs.data : nil %> - -
-
-
-

Idle Jobs

-

- octa_client.idle_jobs.find + octa_client.idle_jobs.logs — inspect a specific job by node_id and job_id. -

-
-
- <% if playground_mock_active? %> - Mock: <%= playground_mock_scenario %> - <% end %> -
-
- -
- <%= form_with url: playground_idle_jobs_path, method: :get, local: true, class: "grid gap-4 md:grid-cols-[minmax(0,10rem)_minmax(0,10rem)_auto]" do %> -
- - <%= text_field_tag :node_id, @node_id, class: "w-full rounded-md border border-slate-700 bg-slate-800 px-3 py-2 font-mono text-xs text-slate-200 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500" %> -
-
- - <%= text_field_tag :job_id, @job_id, class: "w-full rounded-md border border-slate-700 bg-slate-800 px-3 py-2 font-mono text-xs text-slate-200 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500" %> -
-
- <%= button_tag "Load Job", type: "submit", class: "rounded border border-cyan-500/20 bg-cyan-500/10 px-3 py-2 text-xs font-medium text-cyan-400 transition-colors hover:bg-cyan-500/20 hover:text-cyan-300" %> -
- <% end %> -
- -
-
-
-
-

Status

- octa_client.idle_jobs.find(node_id: <%= @node_id %>, job_id: <%= @job_id %>) -
-
- - <% if params[:node_id].blank? || params[:job_id].blank? %> -
Enter a node ID and job ID to load the job.
- <% elsif job_error %> - <%= render "playground/shared/error_card", error: job_error, max_height: "max-h-56" %> - <% elsif job_data.present? %> - <%= render "playground/shared/json_viewer", data: job_data, max_height: "max-h-[28rem]" %> - <% else %> -
No job response available.
- <% end %> -
- -
-
-
-

Logs

- octa_client.idle_jobs.logs(node_id: <%= @node_id %>, job_id: <%= @job_id %>) -
-
- - <% if params[:node_id].blank? || params[:job_id].blank? %> -
Logs are loaded together with job details.
- <% elsif logs_error %> - <%= render "playground/shared/error_card", error: logs_error, max_height: "max-h-56" %> - <% elsif logs_data.present? %> - <%= render "playground/shared/json_viewer", data: logs_data, max_height: "max-h-[28rem]" %> - <% else %> -
No log response available.
- <% end %> -
-
-
diff --git a/test/dummy/app/views/playground/network/show.html.erb b/test/dummy/app/views/playground/network/show.html.erb deleted file mode 100644 index 8a3f51e..0000000 --- a/test/dummy/app/views/playground/network/show.html.erb +++ /dev/null @@ -1,80 +0,0 @@ -<% network_data = @network.respond_to?(:data) ? @network.data : nil %> -<% network_error = @network if @network.is_a?(OctaSpace::Error) %> -<% config_snapshot = { - urls: playground_config.urls, - mock_scenario: playground_mock_scenario, - open_timeout: playground_config.open_timeout, - read_timeout: playground_config.read_timeout, - write_timeout: playground_config.write_timeout, - keep_alive: playground_config.keep_alive?, - pool_size: playground_config.pool_size, - pool_timeout: playground_config.pool_timeout, - idle_timeout: playground_config.idle_timeout, - max_retries: playground_config.max_retries, - retry_interval: playground_config.retry_interval, - backoff_factor: playground_config.backoff_factor -} %> - -
-
-
-

Network Stats

- public - <% if playground_mock_active? %> - mock - <% end %> -
-

- Public endpoint — no API key required. Compare the current transport payload with the Ruby client configuration side by side. -

-
- -
-
-
-
-

Ruby SDK

- octa_client.network.info -
- <%= link_to "Refetch", playground_network_path, class: "rounded border border-slate-700 px-2.5 py-1 text-xs text-slate-400 transition-colors hover:border-slate-600 hover:text-slate-200" %> -
- -
-
status: <%= @network.respond_to?(:status) ? @network.status : "—" %>
-
request_id: <%= @network.respond_to?(:request_id) ? @network.request_id || "—" : "—" %>
-
- - <% if network_error %> - <%= render "playground/shared/error_card", error: network_error %> - <% elsif network_data %> - <%= render "playground/shared/json_viewer", data: network_data %> - <% else %> -
No network payload available.
- <% end %> -
- -
-
-
-

Transport

- <%= playground_transport_label %> -
- <%= playground_transport_stats[:mode] %> -
- -
-
description: <%= playground_transport_description %>
-
timeouts: <%= playground_timeout_label %>
-
keep_alive: <%= playground_config.keep_alive? %>
-
max_retries: <%= playground_config.max_retries %>
-
- - <%= render "playground/shared/json_viewer", data: config_snapshot %> -
-
- -
-
Client Configuration
- <%= render "playground/shared/json_viewer", data: config_snapshot, max_height: "max-h-24" %> -
-
diff --git a/test/dummy/app/views/playground/nodes/index.html.erb b/test/dummy/app/views/playground/nodes/index.html.erb deleted file mode 100644 index b27d941..0000000 --- a/test/dummy/app/views/playground/nodes/index.html.erb +++ /dev/null @@ -1,83 +0,0 @@ -<% nodes_data = @nodes.respond_to?(:data) ? Array(@nodes.data) : [] %> -<% nodes_error = @nodes if @nodes.is_a?(OctaSpace::Error) %> -<% selected_error = @selected_node if @selected_node.is_a?(OctaSpace::Error) %> -<% selected_node_data = @selected_node.respond_to?(:data) ? @selected_node.data : nil %> - -
-
-
-

Nodes

-

- octa_client.nodes.list + octa_client.nodes.find(id) — click a row to load detail. -

-
-
- <% if playground_mock_active? %> - Mock: <%= playground_mock_scenario %> - <% end %> - <%= link_to "Refetch", playground_nodes_path(request.query_parameters.except("selected_id", "node_tab")), class: "rounded border border-slate-700 px-3 py-1.5 text-xs text-slate-400 transition-colors hover:border-slate-600 hover:text-slate-200" %> -
-
- - <% if nodes_error %> - <%= render "playground/shared/error_card", error: nodes_error %> - <% elsif nodes_data.any? %> -
-
-
- <%= nodes_data.length %> nodes - <% if selected_node_data || selected_error %> - <%= link_to "Close detail", playground_nodes_path(request.query_parameters.except("selected_id", "node_tab")), class: "text-xs text-slate-500 transition-colors hover:text-slate-300" %> - <% end %> -
- -
- - - - - - - - - - - - - <% nodes_data.each do |node| %> - <% gpu_entries = Array(node.dig("system", "gpus") || node.dig("data", "gpus")) %> - <% gpu_count = gpu_entries.length %> - <% gpu_model = gpu_entries.first&.dig("model") || node.dig("system", "gpu") || node.dig("system", "gpu_model") || "—" %> - <% ram_bytes = node.dig("system", "memory", "size") || node.dig("system", "memory") || node.dig("data", "memory_size") || node.dig("system", "ram") %> - <% location = [node.dig("location", "city"), node.dig("location", "country")].compact.reject(&:blank?).join(", ") %> - <% selected = @selected_node_id.to_s == node["id"].to_s %> - '" class="cursor-pointer border-b border-slate-800 text-xs transition-colors hover:bg-slate-800/60 <%= selected ? 'bg-slate-800/80' : '' %>"> - - - - - - - - <% end %> - -
IDLocationCPUGPURAMState
- <%= node["id"] %> - <%= location.presence || "—" %><%= node.dig("system", "cpu_cores") || node.dig("data", "cpu_cores") || "—" %> cores<%= gpu_count.positive? ? "#{gpu_count}× " : "" %><%= gpu_model %><%= ram_bytes.is_a?(Numeric) && ram_bytes > 1024 ? "#{(ram_bytes.to_f / 1024 / 1024 / 1024).round} GB" : (ram_bytes.present? ? "#{ram_bytes} GB" : "—") %> - "><%= node["state"] || "unknown" %> -
-
-
- - <% if selected_error %> - <%= render "playground/shared/error_card", error: selected_error %> - <% elsif selected_node_data %> - <%= render "playground/shared/node_detail_panel", - node: selected_node_data, - close_path: playground_nodes_path(request.query_parameters.except("selected_id", "node_tab")) %> - <% end %> -
- <% else %> -
No nodes found.
- <% end %> -
diff --git a/test/dummy/app/views/playground/nodes/show.html.erb b/test/dummy/app/views/playground/nodes/show.html.erb deleted file mode 100644 index fa51e54..0000000 --- a/test/dummy/app/views/playground/nodes/show.html.erb +++ /dev/null @@ -1,22 +0,0 @@ -<% node_data = @node.respond_to?(:data) ? @node.data : nil %> -<% node_error = @node if @node.is_a?(OctaSpace::Error) %> - -
-
-
-

Nodes

-

- Direct node view for octa_client.nodes.find(<%= params[:id] %>). -

-
- <%= link_to "Back to list", playground_nodes_path(selected_id: params[:id]), class: "rounded border border-slate-700 px-3 py-1.5 text-xs text-slate-400 transition-colors hover:border-slate-600 hover:text-slate-200" %> -
- - <% if node_error %> - <%= render "playground/shared/error_card", error: node_error %> - <% elsif node_data %> - <%= render "playground/shared/node_detail_panel", node: node_data, node_id: params[:id], close_path: playground_nodes_path %> - <% else %> -
Node payload unavailable.
- <% end %> -
diff --git a/test/dummy/app/views/playground/services/show.html.erb b/test/dummy/app/views/playground/services/show.html.erb deleted file mode 100644 index 7e91cf3..0000000 --- a/test/dummy/app/views/playground/services/show.html.erb +++ /dev/null @@ -1,134 +0,0 @@ -<% mr_data = @mr_catalog.respond_to?(:data) ? Array(@mr_catalog.data) : [] %> -<% render_data = @render_catalog.respond_to?(:data) ? Array(@render_catalog.data) : [] %> -<% vpn_data = @vpn_catalog.respond_to?(:data) ? Array(@vpn_catalog.data) : [] %> -<% tabs = [ - {id: "mr", label: "Machine Rental Marketplace", hook: "octa_client.services.mr.list", path: "/services/mr", data: mr_data, error: (@mr_catalog if @mr_catalog.is_a?(OctaSpace::Error))}, - {id: "render", label: "Render Marketplace", hook: "octa_client.services.render.list", path: "/services/render", data: render_data, error: (@render_catalog if @render_catalog.is_a?(OctaSpace::Error))}, - {id: "vpn", label: "VPN Relay Marketplace", hook: "octa_client.services.vpn.list", path: "/services/vpn", data: vpn_data, error: (@vpn_catalog if @vpn_catalog.is_a?(OctaSpace::Error))} -] %> -<% active = tabs.find { |tab| tab[:id] == @active_tab } || tabs.first %> -<% active_status = active[:error] ? "error" : "success" %> -<% - offer_label = lambda do |tab_id, item| - case tab_id - when "vpn" - item["residential"] ? "Residential" : "Datacenter" - else - item["gpu"].presence || item["gpus"]&.first&.dig("model") || "CPU" - end - end - - pricing_label = lambda do |tab_id, item| - case tab_id - when "vpn" - usd = item["traffic_price_usd"] - ether = item["traffic_price_ether"] - parts = [] - parts << "$#{usd}/GB" if usd.present? - parts << "#{ether} ETH/GB" if ether.present? - parts.presence&.join(" · ") || "—" - else - base = item["base_usd"] || item["base"] - traffic = item["traffic_usd"] || item["traffic"] - parts = [] - parts << "$#{base}/hr" if base.present? - parts << "$#{traffic}/GB" if traffic.present? - parts.presence&.join(" · ") || "—" - end - end -%> - -
-
-
-

Services

-

- Marketplace catalog endpoints for MR, Render, and VPN. Uses the Ruby SDK client against the currently selected transport. -

-
-
- <% if playground_mock_active? %> - Mock: <%= playground_mock_scenario %> - <% end %> - <%= link_to "Refetch", playground_services_path(request.query_parameters.merge(tab: @active_tab)), class: "rounded border border-slate-700 px-3 py-1.5 text-xs text-slate-400 transition-colors hover:border-slate-600 hover:text-slate-200" %> -
-
- -
- Start mutations (mr.create, render.create, vpn.create) exist on octa_client.services.* but are intentionally not wired up here — use Diagnostics for manual request inspection. -
- -
- Bandwidth below is normalized for display with OctaSpace::PayloadHelpers.normalize_marketplace_bandwidth when live payload values look like raw bytes-per-second. Raw JSON remains unchanged below. -
- -
-
- <% tabs.each do |tab| %> - <%= link_to playground_services_path(request.query_parameters.merge(tab: tab[:id])), - class: "px-4 py-2.5 text-sm transition-colors #{@active_tab == tab[:id] ? 'border-b-2 border-cyan-500 text-cyan-400 -mb-px font-medium' : 'text-slate-500 hover:text-slate-300'}" do %> - <%= tab[:label] %> - <% end %> - <% end %> -
- -
-
- <%= active[:hook] %> - - status: - <%= active_status %> - -
- - <% if active[:error] %> - <%= render "playground/shared/error_card", error: active[:error] %> - <% else %> -
-
- <%= active[:data].is_a?(Array) ? "#{active[:data].length} items" : "#{active[:data].to_h.keys.length} keys" %> -
- <% if active[:data].is_a?(Array) && active[:data].any? %> -
-
Marketplace Summary
-
- - - - - - - - - - - - <% active[:data].each do |item| %> - - - - - - - - <% end %> - -
NodeLocationOfferBandwidth ↓/↑Pricing
#<%= item["node_id"] || "—" %><%= [item["city"], item["country"]].compact_blank.join(", ").presence || "—" %><%= offer_label.call(active[:id], item) %> - <%= playground_format_mbps(item["net_down_mbits"]) %> / <%= playground_format_mbps(item["net_up_mbits"]) %> - <%= pricing_label.call(active[:id], item) %>
-
-
- <% end %> -
- <%= render "playground/shared/json_viewer", data: active[:data], max_height: "max-h-[32rem]" %> -
-
- <% end %> -
-
- -
-
Request Reference
- <%= render "playground/shared/json_viewer", data: {method: "GET", path: active[:path], resource: active[:hook], transport: playground_transport_label}, max_height: "max-h-20" %> -
-
diff --git a/test/dummy/app/views/playground/sessions/index.html.erb b/test/dummy/app/views/playground/sessions/index.html.erb deleted file mode 100644 index b0e64cf..0000000 --- a/test/dummy/app/views/playground/sessions/index.html.erb +++ /dev/null @@ -1,138 +0,0 @@ -<% current_data = @current_sessions.respond_to?(:data) ? Array(@current_sessions.data) : nil %> -<% recent_data = @recent_sessions.respond_to?(:data) ? Array(@recent_sessions.data) : nil %> -<% current_error = @current_sessions if @current_sessions.is_a?(OctaSpace::Error) %> -<% recent_error = @recent_sessions if @recent_sessions.is_a?(OctaSpace::Error) %> -<% tabs = [ - {id: "current", label: "Current", description: "Active sessions"}, - {id: "recent", label: "Recent", description: "Last 50 completed + active"} -] %> - -
-
-
-

Sessions

-

- octa_client.sessions.list for discovery, with stop actions wired through octa_client.services.session(uuid).stop. -

-
-
- <% if playground_mock_active? %> - Mock: <%= playground_mock_scenario %> - <% end %> - <%= link_to "Refetch", playground_sessions_path(request.query_parameters.merge(tab: @active_tab)), class: "rounded border border-slate-700 px-3 py-1.5 text-xs text-slate-400 transition-colors hover:border-slate-600 hover:text-slate-200" %> -
-
- - <% active_data = @active_tab == "recent" ? recent_data : current_data %> - <% active_error = @active_tab == "recent" ? recent_error : current_error %> - <% active_recent = @active_tab == "recent" %> - <% active_hook = active_recent ? "octa_client.sessions.list(recent: true)" : "octa_client.sessions.list" %> - <% base_query = request.query_parameters.except("stop_uuid") %> - <% stop_target = current_data&.find { |session| session["uuid"] == @stop_uuid } if @stop_uuid.present? %> - <% stop_notice = flash[:playground_stop_notice] %> - <% stop_error = flash[:playground_stop_error] %> - - <% if stop_notice.present? %> -
-
- -
-
Stop request submitted
-

<%= stop_notice["message"] %>

- <% if stop_notice["request_id"].present? %> -

request_id: <%= stop_notice["request_id"] %>

- <% end %> -
-
-
- <% end %> - - <% if stop_error.present? %> -
-
- -
-
Stop request failed
-

<%= stop_error %>

-
-
-
- <% end %> - - <% if active_recent %> -
- Recent session payloads may serialize telemetry like duration, rx, and tx as strings. For finished-session logs, use Diagnostics with services.session.logs and recent: true. -
- <% end %> - -
-
- <% tabs.each do |tab| %> - <%= link_to playground_sessions_path(request.query_parameters.merge(tab: tab[:id])), - class: "flex items-center gap-2 px-4 py-2.5 text-sm transition-colors #{@active_tab == tab[:id] ? '-mb-px border-b-2 border-cyan-500 font-medium text-cyan-400' : 'text-slate-500 hover:text-slate-300'}" do %> - <%= tab[:label] %> - <%= tab[:description] %> - <% end %> - <% end %> -
- -
-
- <%= active_hook %> - - status: - <%= active_error ? "error" : "success" %> - -
- - <% if active_error %> - <%= render "playground/shared/error_card", error: active_error %> - <% elsif active_data %> - <% if active_data.empty? %> -
- No <%= active_recent ? "recent" : "active" %> sessions found. -
- <% else %> -
-
- <%= active_data.length %> session<%= "s" unless active_data.length == 1 %> -
- <%= render "playground/shared/sessions_table", sessions: active_data, recent: active_recent %> -
- <% end %> - <% else %> -
- No response available. -
- <% end %> -
-
- - <% if active_data.present? && !active_error %> -
-
Raw JSON
- <%= render "playground/shared/json_viewer", data: active_data, max_height: "max-h-80" %> -
- <% end %> -
- -<% if stop_target.present? %> -
-
-

Stop session?

-

- This will stop session <%= stop_target["uuid"] %>. This action cannot be undone. -

-
- <%= link_to "Cancel", - playground_sessions_path(base_query.merge(tab: @active_tab)), - class: "rounded border border-slate-700 px-3 py-1.5 text-xs text-slate-400 transition-colors hover:border-slate-600 hover:text-slate-200" %> - <%= form_with url: playground_sessions_stop_path, method: :post, local: true do %> - <%= hidden_field_tag :uuid, stop_target["uuid"] %> - <%= hidden_field_tag :tab, @active_tab %> - <%= button_tag "Stop session", type: "submit", class: "rounded bg-red-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-red-500" %> - <% end %> -
-
-
-<% end %> diff --git a/test/dummy/app/views/playground/shared/_error_card.html.erb b/test/dummy/app/views/playground/shared/_error_card.html.erb deleted file mode 100644 index f0c9f74..0000000 --- a/test/dummy/app/views/playground/shared/_error_card.html.erb +++ /dev/null @@ -1,28 +0,0 @@ -<% max_height = local_assigns.fetch(:max_height, "max-h-72") %> - -
-
- -
-
<%= error.class.name %>
-

<%= error.message.presence || "Request failed" %>

-
-
- -
-
-
Status
-
<%= error.status || "—" %>
-
-
-
Request ID
-
<%= error.request_id || "—" %>
-
-
-
Type
-
<%= error.class.name.demodulize %>
-
-
- - <%= render "playground/shared/json_viewer", data: playground_error_payload(error), max_height: max_height %> -
diff --git a/test/dummy/app/views/playground/shared/_json_viewer.html.erb b/test/dummy/app/views/playground/shared/_json_viewer.html.erb deleted file mode 100644 index 2dafa09..0000000 --- a/test/dummy/app/views/playground/shared/_json_viewer.html.erb +++ /dev/null @@ -1,8 +0,0 @@ -<% max_height = local_assigns.fetch(:max_height, "h-[42rem]") %> - -
- -
<%= playground_json_html(data) %>
-
diff --git a/test/dummy/app/views/playground/shared/_node_detail_panel.html.erb b/test/dummy/app/views/playground/shared/_node_detail_panel.html.erb deleted file mode 100644 index 3405e40..0000000 --- a/test/dummy/app/views/playground/shared/_node_detail_panel.html.erb +++ /dev/null @@ -1,58 +0,0 @@ -<% node_tab = request.query_parameters.fetch("node_tab", "summary") %> -<% node_id = node["id"] || local_assigns[:node_id] %> -<% gpu_entries = Array(node.dig("system", "gpus") || node.dig("data", "gpus")) %> -<% location = [node.dig("location", "city"), node.dig("location", "country")].compact.reject(&:blank?).join(", ") %> -<% ram_value = node.dig("system", "memory", "size") || node.dig("system", "memory") || node.dig("data", "memory_size") || node.dig("system", "ram") %> -<% formatted_ram = if ram_value.is_a?(Numeric) && ram_value > 1024 - "#{(ram_value.to_f / 1024 / 1024 / 1024).round} GB" -elsif ram_value.present? - "#{ram_value} GB" -else - "—" -end %> -<% rows = [ - ["State", node["state"] || node.dig("status") || "—"], - ["IP", node["ip"] || node["public_ip"] || "—"], - ["Location", location.presence || "—"], - ["CPU", node.dig("data", "cpu_model_name") || node.dig("system", "cpu_model_name") || node.dig("system", "cpu") || "—"], - ["Cores", node.dig("data", "cpu_cores") || node.dig("system", "cpu_cores") || "—"], - ["RAM", formatted_ram], - ["CUDA", node.dig("data", "cuda_version") || node.dig("system", "cuda_version") || "—"], - ["OS", node.dig("data", "os_version") || node.dig("system", "os") || "—"], - ["Driver", node.dig("data", "gpu_driver_version") || node.dig("system", "gpu_driver_version") || "—"], - ["Uptime", playground_format_duration(node.dig("data", "uptime") || node["uptime"])], - ["GPU vendor", node.dig("data", "gpu_vendor") || gpu_entries.first&.dig("vendor") || "—"] -] %> - -
-
-
-

Node #<%= node_id %>

- octa_client.nodes.find(<%= node_id %>) -
- <% if local_assigns[:close_path].present? %> - <%= link_to "Close detail", close_path, class: "text-xs text-slate-500 transition-colors hover:text-slate-300" %> - <% end %> -
- -
- <% {"summary" => "Summary", "raw" => "Raw JSON"}.each do |value, label| %> - <%= link_to label, - playground_query_url(:node_tab, value), - class: "px-3 py-1.5 text-xs transition-colors #{node_tab == value ? 'border-b-2 border-cyan-500 text-cyan-400 -mb-px' : 'text-slate-500 hover:text-slate-300'}" %> - <% end %> -
- - <% if node_tab == "raw" %> - <%= render "playground/shared/json_viewer", data: node %> - <% else %> -
- <% rows.each do |label, value| %> -
- <%= label %> - <%= value %> -
- <% end %> -
- <% end %> -
diff --git a/test/dummy/app/views/playground/shared/_resource_panel.html.erb b/test/dummy/app/views/playground/shared/_resource_panel.html.erb deleted file mode 100644 index 3577417..0000000 --- a/test/dummy/app/views/playground/shared/_resource_panel.html.erb +++ /dev/null @@ -1,52 +0,0 @@ -<% tab_param = local_assigns.fetch(:tab_param, :tab).to_s %> -<% current_tab = request.query_parameters.fetch(tab_param, local_assigns.fetch(:default_tab, "formatted")) %> -<% locked = local_assigns.fetch(:locked, false) %> -<% error = local_assigns[:error] %> -<% empty_copy = local_assigns.fetch(:empty_copy, "No data available.") %> -<% locked_copy = local_assigns.fetch(:locked_copy, "Enter an API key in the sidebar to load this resource") %> -<% badge = local_assigns[:badge] %> -<% has_data = playground_present?(data) %> - -
-
-
-

<%= title %>

- <% if hook.present? %> - <%= hook %> - <% end %> -
- - <% if badge.present? %> - <%= badge %> - <% end %> -
- - <% if locked %> -
- - - - <%= locked_copy %> -
- <% elsif error.present? %> - <%= render "playground/shared/error_card", error: error %> - <% elsif has_data %> -
- <% {"formatted" => "Formatted", "raw" => "Raw JSON"}.each do |value, label| %> - <%= link_to label, - playground_query_url(tab_param, value), - class: "px-3 py-1.5 text-xs transition-colors #{current_tab == value ? 'border-b-2 border-cyan-500 text-cyan-400 -mb-px' : 'text-slate-500 hover:text-slate-300'}" %> - <% end %> -
- - <% if current_tab == "raw" %> - <%= render "playground/shared/json_viewer", data: data, max_height: local_assigns.fetch(:raw_max_height, "h-[42rem]") %> - <% else %> - <%= formatted %> - <% end %> - <% else %> -
- <%= empty_copy %> -
- <% end %> -
diff --git a/test/dummy/app/views/playground/shared/_sessions_table.html.erb b/test/dummy/app/views/playground/shared/_sessions_table.html.erb deleted file mode 100644 index 90b5a80..0000000 --- a/test/dummy/app/views/playground/shared/_sessions_table.html.erb +++ /dev/null @@ -1,102 +0,0 @@ -<% - service_label = lambda do |service| - case service.to_s - when "mr" then "RENTAL" - when "render" then "RENDER" - when "vpn" then "VPN" - else "—" - end - end - - service_badge_class = lambda do |service| - case service.to_s - when "mr" then "border border-sky-500/30 bg-sky-900/30 text-sky-400" - when "render" then "border border-orange-500/30 bg-orange-900/30 text-orange-400" - when "vpn" then "border border-indigo-500/30 bg-indigo-900/30 text-indigo-400" - else "border border-slate-600/40 bg-slate-700/50 text-slate-400" - end - end - - normal_reason = lambda do |reason| - [0, 1].include?(reason.to_i) - end - - session_status = lambda do |session, recent_mode| - if recent_mode && session["termination_reason"].present? - normal_reason.call(session["termination_reason"]) ? "Normal" : "Error" - elsif session["is_ready"] - "Ready" - elsif session["progress"].present? - session["progress"] - else - session["status"].presence || "Starting" - end - end - - status_badge_class = lambda do |session, recent_mode| - if recent_mode && session["termination_reason"].present? - normal_reason.call(session["termination_reason"]) ? "border border-emerald-500/30 bg-emerald-500/15 text-emerald-400" : "border border-rose-500/30 bg-rose-500/15 text-rose-400" - elsif session["is_ready"] - "border border-emerald-500/30 bg-emerald-500/15 text-emerald-400" - else - "border border-amber-500/30 bg-amber-500/15 text-amber-400" - end - end -%> - -<% show_service = sessions.any? { |s| s["service"].present? } %> - -
- - - - - - <% if show_service %> - - <% end %> - - - - - <% unless recent %> - - <% end %> - - <% unless recent %> - - <% end %> - - - - <% sessions.each do |session| %> - - - - <% if show_service %> - - <% end %> - - - - - <% unless recent %> - - <% end %> - - <% unless recent %> - - <% end %> - - <% end %> - -
SessionNode IDServiceStatusStartedDurationTraffic ↓/↑IPAmountAction
"><%= playground_truncate(session["uuid"]) %>#<%= session["node_id"] || "—" %> - "><%= service_label.call(session["service"]) %> - - <%= session_status.call(session, recent) %> - <%= playground_format_datetime(session["started_at"]) %><%= playground_format_duration(session["duration"]) %><%= playground_format_bytes(session["rx"]) %> / <%= playground_format_bytes(session["tx"]) %><%= session["public_ip"] || session["ip"] || "—" %><%= session["charge_amount"] || "—" %> - <%= link_to "Stop", - playground_sessions_path(request.query_parameters.except("stop_uuid").merge(tab: "current", stop_uuid: session["uuid"])), - class: "rounded border border-red-800/50 px-2 py-0.5 text-[11px] text-red-500 transition-colors hover:border-red-600 hover:text-red-400" %> -
-
diff --git a/test/dummy/bin/rails b/test/dummy/bin/rails deleted file mode 100755 index 22f2d8d..0000000 --- a/test/dummy/bin/rails +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -APP_PATH = File.expand_path("../config/application", __dir__) -require_relative "../config/boot" -require "rails/commands" diff --git a/test/dummy/config.ru b/test/dummy/config.ru deleted file mode 100644 index 2797095..0000000 --- a/test/dummy/config.ru +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require_relative "config/environment" - -run Rails.application -Rails.application.load_server diff --git a/test/dummy/config/application.rb b/test/dummy/config/application.rb deleted file mode 100644 index 68849f7..0000000 --- a/test/dummy/config/application.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -require_relative "boot" - -require "rails" -require "action_controller/railtie" -require "action_view/railtie" - -# Load the gem itself -$LOAD_PATH.unshift File.expand_path("../../../lib", __dir__) -require "octaspace" - -module Dummy - class Application < Rails::Application - config.root = File.expand_path("..", __dir__) - config.load_defaults [Rails::VERSION::MAJOR, Rails::VERSION::MINOR].join(".") - config.eager_load = false - config.secret_key_base = "dummy_secret_key_base_for_playground_only" - config.logger = Logger.new($stdout) - config.log_level = :info - end -end diff --git a/test/dummy/config/boot.rb b/test/dummy/config/boot.rb deleted file mode 100644 index 7865da2..0000000 --- a/test/dummy/config/boot.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__) - -require "bundler/setup" diff --git a/test/dummy/config/environment.rb b/test/dummy/config/environment.rb deleted file mode 100644 index e8173e0..0000000 --- a/test/dummy/config/environment.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -require_relative "application" - -Rails.application.initialize! diff --git a/test/dummy/config/initializers/octaspace.rb b/test/dummy/config/initializers/octaspace.rb deleted file mode 100644 index 7f68330..0000000 --- a/test/dummy/config/initializers/octaspace.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -OctaSpace.configure do |config| - config.api_key = ENV.fetch("OCTA_API_KEY", nil) - config.keep_alive = ENV["OCTASPACE_KEEP_ALIVE"] == "true" - config.pool_size = ENV.fetch("RAILS_MAX_THREADS", 5).to_i - # Keep the playground responsive by default; override with OCTASPACE_MAX_RETRIES if needed. - config.max_retries = ENV.fetch("OCTASPACE_MAX_RETRIES", 0).to_i - config.logger = Rails.logger -end diff --git a/test/dummy/config/routes.rb b/test/dummy/config/routes.rb deleted file mode 100644 index c1a075d..0000000 --- a/test/dummy/config/routes.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -Rails.application.routes.draw do - scope path: "playground", as: "playground", module: "playground" do - get "dashboard", to: "dashboard#show" - get "network", to: "network#show" - get "account", to: "accounts#show" - get "apps", to: "apps#show" - get "nodes", to: "nodes#index" - get "nodes/:id", to: "nodes#show", as: :node - get "sessions", to: "sessions#index" - post "sessions/stop", to: "sessions#stop", as: :sessions_stop - get "services", to: "services#show" - get "idle-jobs", to: "idle_jobs#show", as: :idle_jobs - get "diagnostics", to: "diagnostics#show" - post "diagnostics/run", to: "diagnostics#run", as: :diagnostics_run - post "diagnostics/smoke", to: "diagnostics#smoke", as: :diagnostics_smoke - patch "settings", to: "settings#update", as: :settings - delete "settings", to: "settings#destroy" - delete "request-log", to: "request_logs#destroy", as: :request_log - end - - root to: redirect("/playground/dashboard") -end diff --git a/test/dummy/public/playground.css b/test/dummy/public/playground.css deleted file mode 100644 index 5b28ece..0000000 --- a/test/dummy/public/playground.css +++ /dev/null @@ -1,38 +0,0 @@ -.json-key { color: rgb(148 163 184); } -.json-string { color: rgb(34 211 238); } -.json-number { color: rgb(251 191 36); } -.json-bool { color: rgb(129 140 248); } -.json-null { color: rgb(248 113 113); } - -::-webkit-scrollbar { width: 8px; height: 8px; } -::-webkit-scrollbar-track { background: rgb(15 23 42); } -::-webkit-scrollbar-thumb { background: rgb(51 65 85); border-radius: 9999px; } -::-webkit-scrollbar-thumb:hover { background: rgb(71 85 105); } - -/* Page loader ---------------------------------------------------------------- - Visibility is controlled from JS after 0.4 s, so fast responses never - produce a visible flash. */ -#page-loader { - display: none; - position: absolute; - inset: 0; - z-index: 20; - flex-direction: column; - align-items: center; - justify-content: center; - background: rgba(2, 6, 23, 0.78); -} -#page-loader.loader-active { - display: flex; - opacity: 1; -} - -.loader-spinner { - width: 28px; - height: 28px; - border: 2.5px solid rgba(148, 163, 184, 0.18); - border-top-color: rgb(34, 211, 238); - border-radius: 50%; - animation: loader-spin 0.7s linear infinite; -} -@keyframes loader-spin { to { transform: rotate(360deg); } } diff --git a/test/dummy/public/playground.js b/test/dummy/public/playground.js deleted file mode 100644 index c5133ce..0000000 --- a/test/dummy/public/playground.js +++ /dev/null @@ -1,145 +0,0 @@ -document.addEventListener("click", async (event) => { - const button = event.target.closest("[data-copy-json]") - if (!button) return - - const viewer = button.closest("[data-json-viewer]") - const pre = viewer && viewer.querySelector("pre") - if (!pre) return - - const originalLabel = button.dataset.label || button.textContent || "Copy" - - try { - await navigator.clipboard.writeText(pre.textContent || "") - button.textContent = button.dataset.copiedLabel || "Copied!" - } catch (error) { - button.textContent = "Copy failed" - } - - button.disabled = true - window.setTimeout(() => { - button.textContent = originalLabel - button.disabled = false - }, 1500) -}) - -document.addEventListener("change", (event) => { - const field = event.target.closest("[data-autosubmit-setting]") - if (!field || !field.form) return - - const initialValue = field.dataset.initialValue ?? "" - const currentValue = field.value ?? "" - if (currentValue === initialValue) return - - field.form.requestSubmit() -}) - -// Page loader: appears only after 400 ms of waiting, so fast responses do not flash. -const LOADER_SKIP = ["/playground/settings", "/playground/request-log"] -const LOADER_DELAY_MS = 400 -let loaderTimer = null - -const activateLoader = () => { - const element = document.getElementById("page-loader") - if (!element) return - - element.classList.add("loader-active") - element.setAttribute("aria-hidden", "false") -} - -const scheduleLoader = () => { - if (loaderTimer !== null) return - - loaderTimer = window.setTimeout(() => { - loaderTimer = null - activateLoader() - }, LOADER_DELAY_MS) -} - -const hideLoader = () => { - if (loaderTimer !== null) { - window.clearTimeout(loaderTimer) - loaderTimer = null - } - - const element = document.getElementById("page-loader") - if (!element) return - - element.classList.remove("loader-active") - element.setAttribute("aria-hidden", "true") -} - -document.addEventListener("submit", (event) => { - const form = event.target - if (!form || !form.action) return - - try { - const pathname = new URL(form.action, window.location.href).pathname - if (LOADER_SKIP.some((value) => pathname === value)) return - } catch (_error) { - return - } - - scheduleLoader() -}) - -document.addEventListener("click", (event) => { - if (!event.target.closest("[data-scroll-target]")) { - if (!(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)) { - const link = event.target.closest("a[href]") - - if (link) { - const href = link.getAttribute("href") - - if (href && !href.startsWith("#") && !href.startsWith("mailto:") && link.target !== "_blank") { - try { - const url = new URL(href, window.location.href) - if (url.origin === window.location.origin) { - scheduleLoader() - } - } catch (_error) { - } - } - } - } - } - - const trigger = event.target.closest("[data-scroll-target]") - if (!trigger) return - - const targetId = trigger.dataset.scrollTarget - if (!targetId) return - - const target = document.getElementById(targetId) - if (!target) return - - event.preventDefault() - target.scrollIntoView({behavior: "smooth", block: "start"}) - - if (typeof target.focus === "function") { - target.focus({preventScroll: true}) - } -}) - -const syncDiagnosticsSelection = () => { - const list = document.querySelector("[data-diagnostics-list]") - const selected = document.querySelector("[data-diagnostics-item][data-selected='true']") - if (!list || !selected) return - - window.requestAnimationFrame(() => { - selected.scrollIntoView({block: "nearest", inline: "nearest"}) - }) -} - -if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", syncDiagnosticsSelection, {once: true}) -} else { - syncDiagnosticsSelection() -} - -window.addEventListener("pageshow", (event) => { - if (event.persisted) { - hideLoader() - } -}) - -window.addEventListener("pagehide", hideLoader) diff --git a/test/dummy/public/service-worker.js b/test/dummy/public/service-worker.js deleted file mode 100644 index 0796f5d..0000000 --- a/test/dummy/public/service-worker.js +++ /dev/null @@ -1 +0,0 @@ -// Intentionally empty: the playground does not register a service worker. \ No newline at end of file diff --git a/test/dummy_diagnostics_test.rb b/test/dummy_diagnostics_test.rb deleted file mode 100644 index 912db61..0000000 --- a/test/dummy_diagnostics_test.rb +++ /dev/null @@ -1,146 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" -require_relative "dummy/config/environment" - -class DummyDiagnosticsTest < Minitest::Test - include FixtureHelpers - - def setup - super - ActionController::Base.allow_forgery_protection = false - @session = ActionDispatch::Integration::Session.new(Rails.application) - @session.host! "127.0.0.1" - end - - def test_diagnostics_run_renders_error_card_for_mock_scenario - @session.patch("/playground/settings", params: {mock_scenario: "401", return_to: "/playground/diagnostics"}) - @session.post("/playground/diagnostics/run", params: {call: "network.info"}) - - assert_equal 200, @session.response.status - assert_includes @session.response.body, "401 Unauthorized" - assert_includes @session.response.body, "OctaSpace::AuthenticationError" - end - - def test_diagnostics_run_executes_vpn_create_mutation - payload = OctaSpace::Playground::PayloadPresets.payload_for("services.vpn.create").merge(node_id: 77) - stub_request(:post, "https://api.octa.space/services/vpn") - .with(body: payload.to_json) - .to_return(status: 201, body: '{"uuid":"vpn-new-123","status":"starting"}', headers: {"Content-Type" => "application/json"}) - - @session.patch("/playground/settings", params: {api_key: "test_key", mock_scenario: "real", return_to: "/playground/diagnostics"}) - @session.post("/playground/diagnostics/run", params: {call: "services.vpn.create", payload_json: JSON.pretty_generate(payload)}) - - assert_equal 200, @session.response.status - assert_includes @session.response.body, "Success" - assert_includes @session.response.body, "vpn-new-123" - end - - def test_diagnostics_run_executes_session_stop_mutation - payload = OctaSpace::Playground::PayloadPresets.payload_for("services.session.stop") - stub_request(:post, "https://api.octa.space/services/sess-abc-123/stop") - .with(body: {score: payload[:score]}.to_json) - .to_return(status: 200, body: '{"status":"stopped"}', headers: {"Content-Type" => "application/json"}) - - @session.patch("/playground/settings", params: {api_key: "test_key", mock_scenario: "real", return_to: "/playground/diagnostics"}) - @session.post("/playground/diagnostics/run", params: {call: "services.session.stop", payload_json: JSON.pretty_generate(payload)}) - - assert_equal 200, @session.response.status - assert_includes @session.response.body, "Success" - assert_includes @session.response.body, "stopped" - end - - def test_diagnostics_run_executes_recent_session_logs_call - payload = OctaSpace::Playground::PayloadPresets.payload_for("services.session.logs") - stub_request(:get, "https://api.octa.space/services/sess-abc-123/logs") - .with(query: {"recent" => "true"}) - .to_return(status: 200, body: fixture("services/session/logs.json"), headers: {"Content-Type" => "application/json"}) - - @session.patch("/playground/settings", params: {api_key: "test_key", mock_scenario: "real", return_to: "/playground/diagnostics"}) - @session.post("/playground/diagnostics/run", params: {call: "services.session.logs", payload_json: JSON.pretty_generate(payload)}) - - assert_equal 200, @session.response.status - assert_includes @session.response.body, "Success" - assert_includes @session.response.body, "octa_client.services.session(uuid).logs" - assert_includes @session.response.body, "services/sess-abc-123/logs?recent=true" - assert_includes @session.response.body, "Container started" - end - - def test_diagnostics_run_reports_invalid_payload_json - @session.patch("/playground/settings", params: {api_key: "test_key", mock_scenario: "real", return_to: "/playground/diagnostics"}) - @session.post("/playground/diagnostics/run", params: {call: "services.vpn.create", payload_json: "{not json}"}) - - assert_equal 200, @session.response.status - assert_includes @session.response.body, "OctaSpace::ValidationError" - assert_includes @session.response.body, "Invalid payload JSON" - end - - def test_diagnostics_smoke_runs_read_only_sdk_smoke_and_renders_json - stub_request(:get, "https://api.octa.space/network") - .to_return(status: 200, body: fixture("network/index.json"), headers: {"Content-Type" => "application/json"}) - stub_request(:get, "https://api.octa.space/accounts") - .to_return(status: 200, body: fixture("accounts/show.json"), headers: {"Content-Type" => "application/json"}) - stub_request(:get, "https://api.octa.space/accounts/balance") - .to_return(status: 200, body: fixture("accounts/balance.json"), headers: {"Content-Type" => "application/json"}) - stub_request(:get, "https://api.octa.space/apps") - .to_return(status: 200, body: fixture("apps/index.json"), headers: {"Content-Type" => "application/json"}) - stub_request(:get, "https://api.octa.space/nodes") - .to_return(status: 200, body: fixture("nodes/index.json"), headers: {"Content-Type" => "application/json"}) - stub_request(:get, "https://api.octa.space/services/mr") - .to_return(status: 200, body: fixture("services/mr/index.json"), headers: {"Content-Type" => "application/json"}) - stub_request(:get, "https://api.octa.space/services/render") - .to_return(status: 200, body: fixture("services/render/index.json"), headers: {"Content-Type" => "application/json"}) - stub_request(:get, "https://api.octa.space/services/vpn") - .to_return(status: 200, body: fixture("services/vpn/index.json"), headers: {"Content-Type" => "application/json"}) - stub_request(:get, "https://api.octa.space/sessions") - .to_return(status: 200, body: fixture("sessions/index.json"), headers: {"Content-Type" => "application/json"}) - stub_request(:get, "https://api.octa.space/sessions") - .with(query: {"recent" => "true"}) - .to_return(status: 200, body: fixture("sessions/recent.json"), headers: {"Content-Type" => "application/json"}) - - @session.patch("/playground/settings", params: {api_key: "test_key", mock_scenario: "real", return_to: "/playground/diagnostics"}) - @session.post("/playground/diagnostics/smoke", params: {call: "network.info"}) - - assert_equal 200, @session.response.status - assert_includes @session.response.body, "SDK Smoke passed" - assert_includes @session.response.body, "sdk_smoke" - assert_includes @session.response.body, "octa_client.sessions.list(recent: true)" - end - - def test_diagnostics_run_executes_idle_job_find - payload = OctaSpace::Playground::PayloadPresets.payload_for("idle_jobs.find") - stub_request(:get, "https://api.octa.space/idle_jobs/69/42") - .to_return(status: 200, body: fixture("idle_jobs/show.json"), headers: {"Content-Type" => "application/json"}) - - @session.patch("/playground/settings", params: {api_key: "test_key", mock_scenario: "real", return_to: "/playground/diagnostics"}) - @session.post("/playground/diagnostics/run", params: {call: "idle_jobs.find", payload_json: JSON.pretty_generate(payload)}) - - assert_equal 200, @session.response.status - assert_includes @session.response.body, "completed" - assert_includes @session.response.body, "octa_client.idle_jobs.find" - end - - def test_diagnostics_show_prefills_payload_from_query_string - payload = OctaSpace::Playground::PayloadPresets.payload_for("services.mr.create").merge( - app: "249b4cb3-3db1-4c06-98a4-772ba88cd81c", - image: "ubuntu:24.04" - ) - - @session.get("/playground/diagnostics", params: {call: "services.mr.create", payload_json: JSON.pretty_generate(payload)}) - - assert_equal 200, @session.response.status - assert_includes @session.response.body, "services.mr.create" - assert_includes @session.response.body, "249b4cb3-3db1-4c06-98a4-772ba88cd81c" - assert_includes @session.response.body, "ubuntu:24.04" - end - - def test_diagnostics_show_marks_selected_card_for_scroll_restoration - @session.get("/playground/diagnostics", params: {call: "services.session.logs"}) - - assert_equal 200, @session.response.status - assert_includes @session.response.body, "data-diagnostics-list" - assert_includes @session.response.body, "data-diagnostics-item" - assert_includes @session.response.body, 'data-selected="true"' - assert_includes @session.response.body, "octa_client.services.session(uuid).logs" - end -end diff --git a/test/dummy_resources_test.rb b/test/dummy_resources_test.rb deleted file mode 100644 index 04330b3..0000000 --- a/test/dummy_resources_test.rb +++ /dev/null @@ -1,76 +0,0 @@ -# frozen_string_literal: true - -require "cgi" -require "test_helper" -require_relative "dummy/config/environment" - -class DummyResourcesTest < Minitest::Test - include FixtureHelpers - - def setup - super - ActionController::Base.allow_forgery_protection = false - @session = ActionDispatch::Integration::Session.new(Rails.application) - @session.host! "127.0.0.1" - @session.patch("/playground/settings", params: {api_key: "test_key", mock_scenario: "real", return_to: "/playground/dashboard"}) - end - - def test_apps_page_renders_app_catalog - stub_request(:get, "https://api.octa.space/apps") - .to_return(status: 200, body: fixture("apps/index.json"), headers: {"Content-Type" => "application/json"}) - - @session.get("/playground/apps") - - assert_equal 200, @session.response.status - assert_includes @session.response.body, "Apps" - assert_includes @session.response.body, "Stable Diffusion" - assert_includes @session.response.body, "Use in MR Create" - assert_includes @session.response.body, CGI.escape("249b4cb3-3db1-4c06-98a4-772ba88cd81c") - assert_includes @session.response.body, CGI.escape("ubuntu:24.04") - assert_includes @session.response.body, 'data-scroll-target="apps-raw-json"' - assert_includes @session.response.body, 'id="apps-raw-json"' - end - - def test_layout_brand_link_points_to_root - stub_request(:get, "https://api.octa.space/apps") - .to_return(status: 200, body: fixture("apps/index.json"), headers: {"Content-Type" => "application/json"}) - - @session.get("/playground/apps") - - assert_equal 200, @session.response.status - assert_includes @session.response.body, 'href="/"' - assert_includes @session.response.body, "SDK Playground" - assert_includes @session.response.body, 'id="page-loader"' - assert_includes @session.response.body, "Loading…" - end - - def test_idle_jobs_page_renders_status_and_logs - stub_request(:get, "https://api.octa.space/idle_jobs/69/42") - .to_return(status: 200, body: fixture("idle_jobs/show.json"), headers: {"Content-Type" => "application/json"}) - stub_request(:get, "https://api.octa.space/idle_jobs/69/42/logs") - .to_return(status: 200, body: fixture("idle_jobs/logs.json"), headers: {"Content-Type" => "application/json"}) - - @session.get("/playground/idle-jobs", params: {node_id: 69, job_id: 42}) - - assert_equal 200, @session.response.status - assert_includes @session.response.body, "Idle Jobs" - assert_includes @session.response.body, "Training complete." - assert_includes @session.response.body, "completed" - end - - def test_services_page_renders_marketplace_summary - stub_request(:get, "https://api.octa.space/services/mr") - .to_return(status: 200, body: fixture("services/mr/index.json"), headers: {"Content-Type" => "application/json"}) - stub_request(:get, "https://api.octa.space/services/render") - .to_return(status: 200, body: fixture("services/render/index.json"), headers: {"Content-Type" => "application/json"}) - stub_request(:get, "https://api.octa.space/services/vpn") - .to_return(status: 200, body: fixture("services/vpn/index.json"), headers: {"Content-Type" => "application/json"}) - - @session.get("/playground/services") - - assert_equal 200, @session.response.status - assert_includes @session.response.body, "Machine Rental Marketplace" - assert_includes @session.response.body, "Marketplace Summary" - assert_includes @session.response.body, "6977.8 Mbps" - end -end diff --git a/test/dummy_sessions_test.rb b/test/dummy_sessions_test.rb deleted file mode 100644 index 2711240..0000000 --- a/test/dummy_sessions_test.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" -require_relative "dummy/config/environment" - -class DummySessionsTest < Minitest::Test - include FixtureHelpers - - def setup - super - ActionController::Base.allow_forgery_protection = false - @session = ActionDispatch::Integration::Session.new(Rails.application) - @session.host! "127.0.0.1" - end - - def test_sessions_stop_posts_stop_request_and_renders_notice - stub_request(:get, "https://api.octa.space/sessions") - .to_return(status: 200, body: fixture("sessions/index.json"), headers: {"Content-Type" => "application/json"}) - stub_request(:get, "https://api.octa.space/sessions") - .with(query: {"recent" => "true"}) - .to_return(status: 200, body: fixture("sessions/index.json"), headers: {"Content-Type" => "application/json"}) - stub_request(:post, "https://api.octa.space/services/sess-abc-123/stop") - .to_return(status: 200, body: '{"status":"stopped"}', headers: {"Content-Type" => "application/json"}) - - @session.patch("/playground/settings", params: {api_key: "test_key", mock_scenario: "real", return_to: "/playground/sessions"}) - @session.post("/playground/sessions/stop", params: {uuid: "sess-abc-123", tab: "current"}) - - assert_equal 303, @session.response.status - assert_equal "http://127.0.0.1/playground/sessions?tab=current", @session.response.location - - @session.follow_redirect! - - assert_equal 200, @session.response.status - assert_includes @session.response.body, "Stop request submitted" - assert_includes @session.response.body, "sess-abc-123" - end - - def test_sessions_stop_without_uuid_redirects_with_error - stub_request(:get, "https://api.octa.space/sessions") - .to_return(status: 200, body: fixture("sessions/index.json"), headers: {"Content-Type" => "application/json"}) - stub_request(:get, "https://api.octa.space/sessions") - .with(query: {"recent" => "true"}) - .to_return(status: 200, body: fixture("sessions/index.json"), headers: {"Content-Type" => "application/json"}) - - @session.patch("/playground/settings", params: {api_key: "test_key", mock_scenario: "real", return_to: "/playground/sessions"}) - @session.post("/playground/sessions/stop", params: {uuid: "", tab: "current"}) - - assert_equal 303, @session.response.status - @session.follow_redirect! - - assert_equal 200, @session.response.status - assert_includes @session.response.body, "Stop request failed" - assert_includes @session.response.body, "Session UUID is required" - end -end diff --git a/test/octaspace/client_test.rb b/test/octaspace/client_test.rb index 713f725..6b25a0d 100644 --- a/test/octaspace/client_test.rb +++ b/test/octaspace/client_test.rb @@ -57,11 +57,9 @@ def test_transport_stats_returns_hash end def test_accepts_injected_transport - config = OctaSpace::Configuration.new - transport = OctaSpace::Transport::MockTransport.new(config, scenario: "mock", delay_seconds: 0) + transport = Object.new client = OctaSpace::Client.new(api_key: "key", transport: transport) assert_same transport, client.instance_variable_get(:@transport) - assert_equal :mock, client.transport_stats[:mode] end end diff --git a/test/octaspace/playground/payload_presets_test.rb b/test/octaspace/playground/payload_presets_test.rb deleted file mode 100644 index 1753cdb..0000000 --- a/test/octaspace/playground/payload_presets_test.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" -require "octaspace/playground/payload_presets" - -class OctaSpace::Playground::PayloadPresetsTest < Minitest::Test - def setup - super - OctaSpace::Playground::PayloadPresets.reset! - end - - def test_mr_preset_matches_live_script_contract - payload = OctaSpace::Playground::PayloadPresets.payload_for("services.mr.create") - - assert_equal 10, payload[:disk_size] - assert_equal "ubuntu:24.04", payload[:image] - assert_match(/\A[0-9a-f-]{36}\z/, payload[:app]) - end - - def test_payload_json_for_returns_pretty_json_object - json = OctaSpace::Playground::PayloadPresets.payload_json_for("services.session.stop") - - assert_includes json, "\"uuid\"" - assert_equal({"uuid" => "sess-abc-123", "score" => 5}, JSON.parse(json)) - end - - def test_logs_preset_defaults_to_recent_finished_session_flow - payload = OctaSpace::Playground::PayloadPresets.payload_for("services.session.logs") - - assert_equal "sess-abc-123", payload[:uuid] - assert_equal true, payload[:recent] - end -end diff --git a/test/octaspace/playground/smoke_runner_test.rb b/test/octaspace/playground/smoke_runner_test.rb deleted file mode 100644 index 344f223..0000000 --- a/test/octaspace/playground/smoke_runner_test.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" -require "octaspace/playground/smoke_runner" - -class OctaSpace::Playground::SmokeRunnerTest < Minitest::Test - def setup - super - @client = test_client - end - - def test_run_returns_passed_summary_for_read_only_suites - stub_get("/network", fixture_path: "network/index.json") - stub_get("/accounts", fixture_path: "accounts/show.json") - stub_get("/accounts/balance", fixture_path: "accounts/balance.json") - stub_get("/apps", fixture_path: "apps/index.json") - stub_get("/nodes", fixture_path: "nodes/index.json") - stub_get("/services/mr", fixture_path: "services/mr/index.json") - stub_get("/services/render", fixture_path: "services/render/index.json") - stub_get("/services/vpn", fixture_path: "services/vpn/index.json") - stub_get("/sessions", fixture_path: "sessions/index.json") - stub_request(:get, "#{StubHelpers::BASE_URL}/sessions") - .with(query: {"recent" => "true"}) - .to_return(status: 200, body: fixture("sessions/recent.json"), headers: json_headers) - - result = OctaSpace::Playground::SmokeRunner.new(client: @client).run - - assert_equal "sdk_smoke", result[:kind] - assert_equal "passed", result[:status] - assert_equal 10, result.dig(:summary, :total) - assert_equal 10, result.dig(:summary, :passed) - assert_equal 0, result.dig(:summary, :failed) - assert_equal "network.info", result[:suites].first[:id] - assert_equal "passed", result[:suites].first[:status] - end -end diff --git a/test/octaspace/resources/services_test.rb b/test/octaspace/resources/services_test.rb index 45fa89e..5ebfbe2 100644 --- a/test/octaspace/resources/services_test.rb +++ b/test/octaspace/resources/services_test.rb @@ -3,6 +3,14 @@ require "test_helper" class OctaSpace::ServicesResourceTest < Minitest::Test + # Request payloads from the removed playground presets. + PAYLOADS = { + "services.mr.create" => {node_id: 1, disk_size: 10, image: "ubuntu:24.04", app: "249b4cb3-3db1-4c06-98a4-772ba88cd81c"}, + "services.render.create" => {node_id: 1, disk_size: 100}, + "services.vpn.create" => {node_id: 1, subkind: "wg"}, + "services.session.stop" => {uuid: "sess-abc-123", score: 5} + }.freeze + def setup super @client = test_client @@ -34,7 +42,7 @@ def test_mr_list_returns_array end def test_mr_create_returns_success - payload = OctaSpace::Playground::PayloadPresets.payload_for("services.mr.create").merge( + payload = PAYLOADS.fetch("services.mr.create").merge( organization_id: 77, project_id: 88 ) @@ -63,7 +71,7 @@ def test_mr_create_returns_success end def test_mr_create_raises_api_error_on_400_object_rejection - payload = OctaSpace::Playground::PayloadPresets.payload_for("services.mr.create") + payload = PAYLOADS.fetch("services.mr.create") stub_request(:post, "#{StubHelpers::BASE_URL}/services/mr") .to_return(status: 400, body: '{"message":"Node not found"}', headers: json_headers) @@ -71,7 +79,7 @@ def test_mr_create_raises_api_error_on_400_object_rejection end def test_mr_create_raises_provision_rejected_error_on_batch_rejection - payload = OctaSpace::Playground::PayloadPresets.payload_for("services.mr.create") + payload = PAYLOADS.fetch("services.mr.create") stub_request(:post, "#{StubHelpers::BASE_URL}/services/mr") .to_return( status: 200, @@ -101,7 +109,7 @@ def test_vpn_list_returns_array end def test_vpn_create_returns_success - payload = OctaSpace::Playground::PayloadPresets.payload_for("services.vpn.create") + payload = PAYLOADS.fetch("services.vpn.create") stub_post("/services/vpn", status: 201, body: '{"uuid":"new-vpn","status":"starting"}') response = @client.services.vpn.create(**payload) assert response.success? @@ -117,7 +125,7 @@ def test_render_list_returns_success end def test_render_create_returns_success - payload = OctaSpace::Playground::PayloadPresets.payload_for("services.render.create") + payload = PAYLOADS.fetch("services.render.create") stub_post("/services/render", status: 201, body: '{"uuid":"new-render","status":"queued"}') response = @client.services.render.create(**payload) assert response.success? @@ -152,7 +160,7 @@ def test_session_proxy_logs_with_recent_flag end def test_session_proxy_stop - payload = OctaSpace::Playground::PayloadPresets.payload_for("services.session.stop") + payload = PAYLOADS.fetch("services.session.stop") stub_post("/services/sess-abc-123/stop", status: 200, body: '{"status":"stopped"}') response = @client.services.session(payload[:uuid]).stop(score: payload[:score]) assert response.success? diff --git a/test/octaspace/transport/mock_transport_test.rb b/test/octaspace/transport/mock_transport_test.rb deleted file mode 100644 index 94d9ab0..0000000 --- a/test/octaspace/transport/mock_transport_test.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -class OctaSpace::MockTransportTest < Minitest::Test - def setup - @config = OctaSpace::Configuration.new - @config.api_key = "test_key" - @transport = OctaSpace::Transport::MockTransport.new(@config, scenario: "slow", delay_seconds: 0) - end - - def test_returns_mock_body_for_slow_scenario - response = @transport.get("/nodes") - - assert_equal 200, response.status - assert_equal "slow", response.data["scenario"] - assert_equal "Mocked 200", response.data["error"] - end - - def test_raises_typed_http_errors - transport = OctaSpace::Transport::MockTransport.new(@config, scenario: "429", delay_seconds: 0) - error = assert_raises(OctaSpace::RateLimitError) { transport.get("/nodes") } - - assert_equal 429, error.status - assert_equal 60, error.retry_after - end - - def test_raises_timeout_error_for_timeout_scenario - transport = OctaSpace::Transport::MockTransport.new(@config, scenario: "timeout", delay_seconds: 0) - - error = assert_raises(OctaSpace::TimeoutError) { transport.get("/nodes") } - assert_match(/Mocked timeout/, error.message) - end - - def test_exposes_mock_transport_stats - assert_equal :mock, @transport.transport_stats[:mode] - assert_equal "slow", @transport.transport_stats[:scenario] - end -end diff --git a/test/test_helper.rb b/test/test_helper.rb index bf1c5be..be68989 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -3,10 +3,7 @@ $LOAD_PATH.unshift File.expand_path("../lib", __dir__) require "octaspace" -require "octaspace/transport/mock_transport" -require "octaspace/playground/payload_presets" require "minitest/autorun" -require "minitest/mock" require "webmock/minitest" require_relative "support/fixture_helpers" From 7181050474fa2f67d2ded6a5d0e23dc307f529e2 Mon Sep 17 00:00:00 2001 From: secretpray Date: Tue, 3 Nov 2026 13:00:45 +0200 Subject: [PATCH 2/9] feat!: bearer auth, dev stand default and next API error handling Transport for the next API (api-dev.octa.space): - Authorization: Bearer ; a pasted "Bearer " prefix and whitespace are stripped; 401 with a non-octa_ak_ key explains that legacy tokens are refused - per-endpoint auth mode: :required raises AuthenticationError before any request when no key is configured, :optional sends the key when present, :none never sends it - default base URL https://api-dev.octa.space (BASE_URL_DEV); the production hosts still serve the legacy API - errors: 400 -> ValidationError, 409 -> ConflictError, 503 -> ServiceUnavailableError; message taken from {"message"}; ApiError exposes retry_after, rate_limit and body - RateLimit-* headers parsed into OctaSpace::RateLimit, the latest available as client.rate_limit; Retry-After as seconds or HTTP date - retries only for GET and idempotent requests, on network errors and 429/502/503/504, honouring Retry-After up to max_retry_after (60 s); POST start/restart are never retried - failover across base_urls only for GET; a request that changes state is never sent to a second host - JSON parsed by the SDK: empty 202/204 bodies are fine, a non-JSON 2xx body raises ApiError - logging is one line per request; headers (API key) and bodies are never logged, and on_request no longer sees headers - faraday-retry dependency removed --- lib/octaspace/client.rb | 17 +- lib/octaspace/configuration.rb | 71 ++- lib/octaspace/errors.rb | 117 +++-- lib/octaspace/resources/network.rb | 2 +- .../resources/services/machine_rental.rb | 2 +- lib/octaspace/resources/services/render.rb | 2 +- .../resources/services/session_proxy.rb | 2 +- lib/octaspace/response.rb | 75 +++- lib/octaspace/transport/base.rb | 60 +-- lib/octaspace/transport/faraday_transport.rb | 239 ++++++---- .../transport/persistent_transport.rb | 72 +-- octaspace.gemspec | 1 - test/octaspace/configuration_test.rb | 19 +- test/octaspace/response_test.rb | 37 +- .../transport/faraday_transport_test.rb | 419 ++++++++++++------ .../transport/persistent_transport_test.rb | 9 +- test/support/stub_helpers.rb | 2 +- 17 files changed, 746 insertions(+), 400 deletions(-) diff --git a/lib/octaspace/client.rb b/lib/octaspace/client.rb index 4d4be18..63809ee 100644 --- a/lib/octaspace/client.rb +++ b/lib/octaspace/client.rb @@ -61,15 +61,14 @@ def shutdown # Transport diagnostics (pool stats when in keep_alive mode) # @return [Hash] def transport_stats - if @transport.respond_to?(:transport_stats) - @transport.transport_stats - elsif @transport.respond_to?(:pool_stats) - {mode: :persistent, pools: @transport.pool_stats} - elsif @config.urls.size > 1 - {mode: :standard, rotator: @transport.instance_variable_get(:@rotator)&.stats} - else - {mode: :standard, url: @config.urls.first} - end + @transport.respond_to?(:transport_stats) ? @transport.transport_stats : {mode: :custom} + end + + # Rate limit state (RateLimit-* headers) of the most recent response that + # carried them, or nil before the first such response. + # @return [OctaSpace::RateLimit, nil] + def rate_limit + @transport.respond_to?(:rate_limit) ? @transport.rate_limit : nil end private diff --git a/lib/octaspace/configuration.rb b/lib/octaspace/configuration.rb index d2029c3..2fffe8e 100644 --- a/lib/octaspace/configuration.rb +++ b/lib/octaspace/configuration.rb @@ -1,6 +1,17 @@ # frozen_string_literal: true module OctaSpace + # Test stand of the next-generation OctaSpace API. It is the default base URL + # until the production host of the new API is announced: the production hosts + # (+api.octa.computer+, +api.octa.space+) still serve the legacy API. + BASE_URL_DEV = "https://api-dev.octa.space" + + # Default API base URL + DEFAULT_BASE_URL = BASE_URL_DEV + + # Prefix of the account API keys issued in Cube (Profile → Credentials) + API_KEY_PREFIX = "octa_ak_" + # Global and per-client configuration for the OctaSpace SDK # # @example Global configuration (Rails initializer) @@ -13,15 +24,22 @@ module OctaSpace class Configuration # --- Authentication --- - # @return [String, nil] API key for Authorization header - attr_accessor :api_key + # 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. + # @return [String, nil] + attr_reader :api_key # --- Connection --- # @return [String] Base URL for the API (single endpoint) attr_accessor :base_url - # @return [Array, nil] Multiple API endpoints — enables URL rotation/failover + # Ordered list of API endpoints. 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] attr_accessor :base_urls # @return [Integer] Seconds to wait for connection to open @@ -49,6 +67,9 @@ class Configuration attr_accessor :idle_timeout # --- Retry --- + # Only GET requests and the idempotent session stop are retried, on network + # errors and on 429/502/503/504. Starting or restarting a service is never + # retried: it costs money and is rate limited. # @return [Integer] Maximum number of retries on transient failures attr_accessor :max_retries @@ -59,9 +80,19 @@ class Configuration # @return [Float] Exponential backoff multiplier attr_accessor :backoff_factor + # @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. + # @return [Integer] seconds + attr_accessor :max_retry_after + # --- Hooks --- - # @return [#call, nil] Callable invoked before each request; receives request context hash + # @return [#call, nil] Callable invoked before each request attempt; receives + # a Hash with :method, :url, :path, :params and :attempt (never the headers, + # so the API key does not leak into logs) attr_accessor :on_request # @return [#call, nil] Callable invoked after each response; receives OctaSpace::Response @@ -69,7 +100,10 @@ class Configuration # --- Logging --- - # @return [Logger, nil] Ruby Logger instance (or any object responding to #debug/#info/#warn/#error) + # Logger for one line per request (method, path, status, duration). + # Headers and bodies are never logged: they carry the API key and, + # for sessions, URLs with credentials. + # @return [Logger, nil] attr_accessor :logger # @return [Symbol] Log level (:debug, :info, :warn, :error) @@ -86,7 +120,7 @@ class Configuration attr_accessor :user_agent DEFAULTS = { - base_url: "https://api.octa.space", + base_url: DEFAULT_BASE_URL, open_timeout: 10, read_timeout: 30, write_timeout: 30, @@ -97,6 +131,8 @@ class Configuration max_retries: 2, retry_interval: 0.5, backoff_factor: 2.0, + max_retry_interval: 10.0, + max_retry_after: 60, ssl_verify: true, log_level: :info }.freeze @@ -106,6 +142,17 @@ def initialize @user_agent = "octaspace-ruby/#{OctaSpace::VERSION} Ruby/#{RUBY_VERSION}" end + # Sets the API key; surrounding whitespace and a leading "Bearer " are + # removed, and an empty value means no key. + # @param value [String, nil] + def api_key=(value) + key = value.to_s.strip.sub(/\Abearer\s+/i, "") + @api_key = key.empty? ? nil : key + end + + # @return [Boolean] whether an API key is configured + def api_key? = !api_key.nil? + # Alias: `persistent` is the Cube-internal term; `keep_alive` is the public SDK term alias_method :persistent, :keep_alive alias_method :persistent=, :keep_alive= @@ -113,12 +160,12 @@ def initialize # @return [Boolean] def keep_alive? = !!keep_alive - # Returns effective list of API URLs. + # Returns effective list of API URLs, without trailing slashes. # base_urls takes priority over base_url; always returns an Array. # @return [Array] def urls - candidates = Array(base_urls).map(&:to_s).reject(&:empty?) - candidates.empty? ? Array(base_url).map(&:to_s).reject(&:empty?) : candidates + candidates = clean_urls(base_urls) + candidates.empty? ? clean_urls(base_url) : candidates end # Deep-clone configuration for per-client overrides @@ -132,5 +179,11 @@ def dup end copy end + + private + + def clean_urls(value) + Array(value).map { |url| url.to_s.strip.sub(%r{/+\z}, "") }.reject(&:empty?) + end end end diff --git a/lib/octaspace/errors.rb b/lib/octaspace/errors.rb index ca8826e..6f4bef7 100644 --- a/lib/octaspace/errors.rb +++ b/lib/octaspace/errors.rb @@ -3,7 +3,14 @@ module OctaSpace # Base error class for all OctaSpace SDK errors class Error < StandardError - attr_reader :response, :status, :request_id + # @return [OctaSpace::Response, nil] the response that caused the error + attr_reader :response + + # @return [Integer, nil] HTTP status + attr_reader :status + + # @return [String, nil] X-Request-Id of the response + attr_reader :request_id def initialize(message = nil, response: nil) @response = response @@ -21,68 +28,78 @@ class NetworkError < Error; end class ConnectionError < NetworkError; end class TimeoutError < NetworkError; end - # API-level errors (HTTP response received, but indicates failure) - class ApiError < Error; 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. + class ApiError < Error + # @return [Integer, nil] seconds to wait before retrying (Retry-After header) + attr_reader :retry_after - # Domain-level rejection where transport succeeded but the provision request - # was rejected by the API payload contract. - class ProvisionRejectedError < Error - attr_reader :rejections + # @return [OctaSpace::RateLimit, nil] rate limit state of this response + attr_reader :rate_limit - def initialize(message = nil, response: nil, rejections: []) - @rejections = Array(rejections) - super(message || build_message(@rejections), response: response) + def initialize(message = nil, response: nil) + @retry_after = response&.retry_after + @rate_limit = response&.rate_limit + super end - private - - def build_message(rejections) - first_reason = - rejections.filter_map do |item| - next unless item.is_a?(Hash) - - item["reason"] || item[:reason] - end.first - - return "Provision request rejected" if first_reason.to_s.empty? - - "Provision request rejected: #{first_reason}" - end + # @return [Object, nil] parsed response body + def body = response&.data end - # 401 Unauthorized + # 400 or 422: the request was rejected as invalid; the message says why + # (for example "tail must be 1..1000" or "Invalid node number"). + class ValidationError < ApiError; end + + # 401: the API key is missing or invalid. The next API expects + # +Authorization: Bearer octa_ak_…+; legacy account tokens are refused. + # Also raised before any request when an endpoint needs a key and the + # client has none. class AuthenticationError < ApiError; end # 403 Forbidden class PermissionError < ApiError; end - # 404 Not Found + # 404: the resource does not exist. For a session this also means it has + # stopped: the API only reports running sessions. class NotFoundError < ApiError; end - # 422 Unprocessable Entity - class ValidationError < ApiError; end - - # 429 Too Many Requests — includes Retry-After header value - class RateLimitError < ApiError - attr_reader :retry_after + # 409: the operation conflicts with the current state, e.g. restarting a + # session that is still starting or restarting. + class ConflictError < ApiError; end - def initialize(message = nil, response: nil) - @retry_after = response&.retry_after - super - end - end + # 429: too many requests. Wait {#retry_after} seconds before retrying. + class RateLimitError < ApiError; end # 5xx Server Errors class ServerError < ApiError; end class BadGatewayError < ServerError; end # 502 - class ServiceUnavailableError < ServerError; end # 503 + + # 503: a backend the API depends on (render nodes, statistics, …) is + # temporarily unavailable. The request itself was fine; retry later. + class ServiceUnavailableError < ServerError; end + class GatewayTimeoutError < ServerError; end # 504 + # Legacy batch-style rejection of services.mr.create (removed with that resource) + class ProvisionRejectedError < Error + attr_reader :rejections + + def initialize(message = nil, response: nil, rejections: []) + @rejections = Array(rejections) + reason = @rejections.filter_map { |item| item["reason"] if item.is_a?(Hash) }.first + super(message || (reason ? "Provision request rejected: #{reason}" : "Provision request rejected"), response: response) + end + end + # HTTP status code → exception class mapping STATUS_ERRORS = { + 400 => ValidationError, 401 => AuthenticationError, 403 => PermissionError, 404 => NotFoundError, + 409 => ConflictError, 422 => ValidationError, 429 => RateLimitError, 502 => BadGatewayError, @@ -90,13 +107,33 @@ class GatewayTimeoutError < ServerError; end # 504 504 => GatewayTimeoutError }.freeze + # Longest error message kept from a response body (e.g. an HTML error page) + MAX_ERROR_MESSAGE_LENGTH = 512 + # Build the appropriate error for a given Response # @param response [OctaSpace::Response] + # @param api_key [String, nil] the key the request was sent with, for a hint on 401 # @return [OctaSpace::ApiError] subclass instance - def self.error_for(response) + def self.error_for(response, api_key: nil) klass = STATUS_ERRORS.fetch(response.status) do response.server_error? ? ServerError : ApiError end - klass.new(response: response) + message = error_message(response) + if response.status == 401 && api_key && !api_key.start_with?(API_KEY_PREFIX) + message += " (the next API expects a key starting with \"#{API_KEY_PREFIX}\"; " \ + "legacy account tokens are not accepted)" + end + klass.new(message, response: response) + end + + # @api private + def self.error_message(response) + data = response.data + if data.is_a?(Hash) + text = data.values_at("message", "error", "detail").find { |v| v.is_a?(String) && !v.empty? } + end + text ||= response.body.to_s.strip + text = "Request failed with status #{response.status}" if text.empty? + (text.length > MAX_ERROR_MESSAGE_LENGTH) ? "#{text[0, MAX_ERROR_MESSAGE_LENGTH]}…" : text end end diff --git a/lib/octaspace/resources/network.rb b/lib/octaspace/resources/network.rb index 2407c3b..61e6365 100644 --- a/lib/octaspace/resources/network.rb +++ b/lib/octaspace/resources/network.rb @@ -11,7 +11,7 @@ class Network < Base # GET /network # @return [OctaSpace::Response] def info - get("/network") + get("/network", auth: :none) end end end diff --git a/lib/octaspace/resources/services/machine_rental.rb b/lib/octaspace/resources/services/machine_rental.rb index 261a6cd..3b9e54f 100644 --- a/lib/octaspace/resources/services/machine_rental.rb +++ b/lib/octaspace/resources/services/machine_rental.rb @@ -19,7 +19,7 @@ class MachineRental < Base # @param params [Hash] optional filter params # @return [OctaSpace::Response] def list(**params) - get("/services/mr", params:) + get("/services/mr", params:, auth: :optional) end # Create (start) a machine rental diff --git a/lib/octaspace/resources/services/render.rb b/lib/octaspace/resources/services/render.rb index 63ab3ca..e8a39e6 100644 --- a/lib/octaspace/resources/services/render.rb +++ b/lib/octaspace/resources/services/render.rb @@ -14,7 +14,7 @@ class Render < Base # @param params [Hash] optional filter params # @return [OctaSpace::Response] def list(**params) - get("/services/render", params:) + get("/services/render", params:, auth: :optional) end # Create (start) a render job diff --git a/lib/octaspace/resources/services/session_proxy.rb b/lib/octaspace/resources/services/session_proxy.rb index e4c9670..b53f47b 100644 --- a/lib/octaspace/resources/services/session_proxy.rb +++ b/lib/octaspace/resources/services/session_proxy.rb @@ -46,7 +46,7 @@ def logs(recent: nil) # @param params [Hash] e.g. { score: 5 } # @return [OctaSpace::Response] def stop(**params) - @transport.post("/services/#{@uuid}/stop", body: params.empty? ? nil : params) + @transport.post("/services/#{@uuid}/stop", body: params.empty? ? nil : params, idempotent: true) end end end diff --git a/lib/octaspace/response.rb b/lib/octaspace/response.rb index 7127e6b..b6db90a 100644 --- a/lib/octaspace/response.rb +++ b/lib/octaspace/response.rb @@ -1,17 +1,54 @@ # frozen_string_literal: true +require "time" + module OctaSpace - # Wraps a raw Faraday response, providing a stable SDK interface + # Rate limit state reported by the API in the +RateLimit-Limit+, + # +RateLimit-Remaining+ and +RateLimit-Reset+ headers (IETF draft format, + # no +X-+ prefix). + # + # The API keeps separate windows (anonymous per IP, authenticated per + # account, and a stricter one for starting and restarting services), so the + # values describe the window of the request they came from. When a request + # is refused with 429, prefer {ApiError#retry_after}: a per-session cooldown + # can demand a longer wait than the window reset. + RateLimit = Data.define(:limit, :remaining, :reset) do + # @param headers [Hash] response headers (case-insensitive lookup) + # @return [RateLimit, nil] nil when no header is present or one is not an integer + def self.from_headers(headers) + values = %w[limit remaining reset].to_h do |name| + raw = headers["ratelimit-#{name}"].to_s.strip + return nil unless raw.empty? || raw.match?(/\A\d+\z/) + + [name.to_sym, raw.empty? ? nil : raw.to_i] + end + values.values.all?(&:nil?) ? nil : new(**values) + end + end + + # An HTTP response of the API, with the JSON body already parsed class Response - attr_reader :status, :headers, :body, :data - - # @param faraday_response [Faraday::Response] - def initialize(faraday_response) - @status = faraday_response.status - @headers = faraday_response.headers - @body = faraday_response.body - # Faraday :json middleware parses JSON body into a Hash/Array automatically - @data = faraday_response.body + # @return [Integer] HTTP status + attr_reader :status + + # @return [Hash] response headers (case-insensitive when built by the SDK transport) + attr_reader :headers + + # @return [String] raw response body + attr_reader :body + + # @return [Hash, Array, nil] parsed JSON body; nil for an empty body (e.g. 202/204) + attr_reader :data + + # @param status [Integer] + # @param headers [Hash] + # @param body [String, nil] raw body + # @param data [Hash, Array, nil] parsed JSON body + def initialize(status:, headers: {}, body: nil, data: nil) + @status = status + @headers = headers + @body = body.to_s + @data = data end # @return [Boolean] true for 2xx status codes @@ -29,8 +66,22 @@ def error? = client_error? || server_error? # @return [String, nil] X-Request-Id header value def request_id = headers["x-request-id"] - # @return [Integer, nil] Retry-After header value in seconds - def retry_after = headers["retry-after"]&.to_i + # Retry-After header as whole seconds: delta-seconds (a fractional value is + # rounded up) or an HTTP date. + # @return [Integer, nil] + def retry_after + raw = headers["retry-after"].to_s.strip + return nil if raw.empty? + return raw.to_f.ceil if raw.match?(/\A\d+(\.\d+)?\z/) + return nil unless raw.match?(/\A[A-Za-z]{3}, \d{2} [A-Za-z]{3} \d{4} \d{2}:\d{2}:\d{2} GMT\z/) + + [(Time.httpdate(raw) - Time.now).ceil, 0].max + rescue ArgumentError + nil + end + + # @return [OctaSpace::RateLimit, nil] values of the RateLimit-* headers + def rate_limit = RateLimit.from_headers(headers) def to_s "#" diff --git a/lib/octaspace/transport/base.rb b/lib/octaspace/transport/base.rb index 41acbd3..3c58b6c 100644 --- a/lib/octaspace/transport/base.rb +++ b/lib/octaspace/transport/base.rb @@ -5,47 +5,47 @@ module Transport # Abstract base class for HTTP transports # # Concrete implementations: FaradayTransport (standard) and - # PersistentTransport (keep-alive with connection pooling). + # PersistentTransport (keep-alive with connection pooling). A custom + # transport (e.g. for tests) only needs to implement {#request}. class Base - # @param path [String] - # @param params [Hash] - # @param headers [Hash] + # Perform a request and return the response, or raise an OctaSpace::Error. + # + # @param method [Symbol] :get, :post, :patch, :put or :delete + # @param path [String] API path, e.g. "/services/compute" + # @param params [Hash] query parameters (nil values are dropped) + # @param body [Hash, Array, nil] JSON body + # @param auth [Symbol] :required (raise AuthenticationError without a key), + # :optional (send the key when configured) or :none (never send it) + # @param idempotent [Boolean] allow retries for a non-GET request the API + # documents as idempotent # @return [OctaSpace::Response] - def get(path, params: {}, headers: {}) - raise NotImplementedError, "#{self.class}#get not implemented" + def request(method, path, params: {}, body: nil, auth: :required, idempotent: false) + raise NotImplementedError, "#{self.class}#request not implemented" end - # @param path [String] - # @param body [Hash, nil] - # @param headers [Hash] - # @return [OctaSpace::Response] - def post(path, body: nil, headers: {}) - raise NotImplementedError, "#{self.class}#post not implemented" + def get(path, params: {}, auth: :required) + request(:get, path, params: params, auth: auth) end - # @param path [String] - # @param body [Hash, nil] - # @param headers [Hash] - # @return [OctaSpace::Response] - def put(path, body: nil, headers: {}) - raise NotImplementedError, "#{self.class}#put not implemented" + def post(path, body: nil, auth: :required, idempotent: false) + request(:post, path, body: body, auth: auth, idempotent: idempotent) end - # @param path [String] - # @param body [Hash, nil] - # @param headers [Hash] - # @return [OctaSpace::Response] - def patch(path, body: nil, headers: {}) - raise NotImplementedError, "#{self.class}#patch not implemented" + def patch(path, body: nil, auth: :required) + request(:patch, path, body: body, auth: auth) end - # @param path [String] - # @param params [Hash] - # @param headers [Hash] - # @return [OctaSpace::Response] - def delete(path, params: {}, headers: {}) - raise NotImplementedError, "#{self.class}#delete not implemented" + def put(path, body: nil, auth: :required) + request(:put, path, body: body, auth: auth) end + + def delete(path, params: {}, auth: :required) + request(:delete, path, params: params, auth: auth) + end + + # @return [OctaSpace::RateLimit, nil] rate limit state of the most recent + # response that carried RateLimit-* headers + def rate_limit = nil end end end diff --git a/lib/octaspace/transport/faraday_transport.rb b/lib/octaspace/transport/faraday_transport.rb index 612f2c0..8325624 100644 --- a/lib/octaspace/transport/faraday_transport.rb +++ b/lib/octaspace/transport/faraday_transport.rb @@ -1,116 +1,185 @@ # frozen_string_literal: true require "faraday" -require "faraday/retry" +require "json" module OctaSpace module Transport # Standard HTTP transport built on Faraday # - # Features: - # - Automatic JSON request/response encoding - # - Configurable retry with exponential backoff + jitter - # - URL rotation / failover across multiple base_urls - # - on_request / on_response hooks - # - Typed exception hierarchy mapped from HTTP status codes + # - +Authorization: Bearer + per endpoint auth mode + # - JSON request encoding and response parsing (empty 202/204 bodies are fine) + # - 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; + # requests that change state are never sent to a second host + # - on_request / on_response hooks, one log line per request + # - Typed exceptions mapped from HTTP status codes # # @example # config = OctaSpace::Configuration.new - # config.api_key = "my_key" + # config.api_key = "octa_ak_..." # transport = OctaSpace::Transport::FaradayTransport.new(config) - # response = transport.get("/nodes") + # transport.get("/accounts/balance").data class FaradayTransport < Base - RETRY_STATUSES = [408, 429, 500, 502, 503, 504].freeze - RETRY_METHODS = %i[get head options put delete].freeze + # Statuses worth retrying: rate limit and transient gateway/backend failures + RETRY_STATUSES = [429, 502, 503, 504].freeze - def initialize(config) + # @param config [OctaSpace::Configuration] + # @param sleeper [#call] waits the given number of seconds between retries + def initialize(config, sleeper: ->(seconds) { sleep(seconds) }) @config = config - @rotator = (config.urls.size > 1) ? Middleware::UrlRotator.new(config.urls) : nil - # Single URL: build one connection upfront for reuse - @connection = build_connection(config.urls.first) unless @rotator - end + @urls = config.urls.freeze + raise ConfigurationError, "At least one base URL is required" if @urls.empty? + + @rotator = (@urls.size > 1) ? Middleware::UrlRotator.new(@urls) : nil + @sleeper = sleeper + @connections = {} + @mutex = Mutex.new + @rate_limit = nil + end + + # (see Base#request) + def request(method, path, params: {}, body: nil, auth: :required, idempotent: false) + method = method.to_sym + headers = build_headers(method, path, auth, body) + retryable = method == :get || idempotent + attempt = 0 + + begin + with_failover(method) do |url| + perform(url, method, path, params: params, body: body, headers: headers, attempt: attempt) + end + rescue NetworkError, ApiError => e + delay = (retryable && attempt < config.max_retries) ? retry_delay(e, attempt) : nil + raise if delay.nil? - def get(path, params: {}, headers: {}) - request(:get, path, params: params, headers: headers) + @sleeper.call(delay) if delay.positive? + attempt += 1 + retry + end end - def post(path, body: nil, headers: {}) - request(:post, path, body: body, headers: headers) - end + # (see Base#rate_limit) + def rate_limit = @mutex.synchronize { @rate_limit } - def put(path, body: nil, headers: {}) - request(:put, path, body: body, headers: headers) + # @return [Hash] transport diagnostics + def transport_stats + stats = {mode: :standard, urls: @urls} + stats[:rotator] = @rotator.stats if @rotator + stats end - def patch(path, body: nil, headers: {}) - request(:patch, path, body: body, headers: headers) - end + private - def delete(path, params: {}, headers: {}) - request(:delete, path, params: params, headers: headers) + attr_reader :config + + # GET requests try every URL in rotation order on network errors; other + # methods go to a single URL so a request that changes state (and may + # have reached the server before a timeout) is never sent twice. + def with_failover(method) + first = @rotator ? @rotator.next_url : @urls.first + urls = (method == :get) ? [first, *(@urls - [first])] : [first] + last_error = nil + + urls.each do |url| + response = yield(url) + @rotator&.mark_success(url) + return response + rescue NetworkError => e + @rotator&.mark_failed(url) + last_error = e + end + + raise last_error end - private + def perform(base_url, method, path, params:, body:, headers:, attempt:) + query = params.to_h.compact + invoke_hook(config.on_request, + {method: method, url: "#{base_url}#{path}", path: path, params: query, attempt: attempt}) - attr_reader :config + started = Process.clock_gettime(Process::CLOCK_MONOTONIC) + raw = begin + execute(connection_for(base_url), method, path.delete_prefix("/"), + query: query, payload: body.nil? ? nil : JSON.generate(body), headers: headers) + rescue Faraday::TimeoutError => e + log(method, base_url, path, "timeout", started) + raise TimeoutError, "Request timed out: #{e.message}" + rescue Faraday::ConnectionFailed, Faraday::SSLError => e + log(method, base_url, path, "connection failed", started) + raise ConnectionError, e.message + end + log(method, base_url, path, raw.status, started) - # Execute HTTP request with optional URL rotation - def request(method, path, params: {}, body: nil, headers: {}) - invoke_hook(config.on_request, {method: method, path: path, params: params}) + response = build_response(raw) + if (rate_limit = response.rate_limit) + @mutex.synchronize { @rate_limit = rate_limit } + end + invoke_hook(config.on_response, response) + raise OctaSpace.error_for(response, api_key: headers.key?("Authorization") ? config.api_key : nil) if response.error? + + response + end + + # Overridden by PersistentTransport to run the request on a pooled connection + def execute(conn, method, path, query:, payload:, headers:) + conn.run_request(method, path, payload, headers) do |req| + req.params.update(query) unless query.empty? + end + end - with_failover do |base_url| - conn = @rotator ? build_connection(base_url) : @connection + def build_response(raw) + text = raw.body.to_s + data = nil + unless text.strip.empty? begin - faraday_resp = execute(conn, method, path, params: params, body: body, headers: headers) - rescue Faraday::ConnectionFailed => e - raise ConnectionError, e.message - rescue Faraday::TimeoutError => e - raise TimeoutError, e.message + data = JSON.parse(text) + rescue JSON::ParserError + # E.g. a proxy or captive portal answering 200 with an HTML page. + if (200..299).cover?(raw.status) + response = Response.new(status: raw.status, headers: raw.headers, body: text) + raise ApiError.new("Response is not valid JSON (status #{raw.status})", response: response) + end end - response = Response.new(faraday_resp) - invoke_hook(config.on_response, response) - raise OctaSpace.error_for(response) if response.error? - response end + Response.new(status: raw.status, headers: raw.headers, body: text, data: data) end - # Execute a single Faraday request - def execute(conn, method, path, params: {}, body: nil, headers: {}) - conn.public_send(method, path) do |req| - req.headers.merge!(default_headers.merge(headers)) - req.params.merge!(params) if params.any? - req.body = body.to_json if body + def build_headers(method, path, auth, body) + headers = {"User-Agent" => config.user_agent, "Accept" => "application/json"} + headers["Content-Type"] = "application/json" unless body.nil? + + unless auth == :none + if config.api_key? + headers["Authorization"] = "Bearer #{config.api_key}" + 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" + end end + headers end - # Try each URL in rotation; mark failures and retry - def with_failover(&block) - urls = @rotator ? @config.urls.dup : [nil] - tried = [] - last_error = nil + # Seconds to wait before the next attempt, or nil when the error must be raised + def retry_delay(error, attempt) + return nil if error.is_a?(ApiError) && !RETRY_STATUSES.include?(error.status) - urls.each do |url| - next if tried.include?(url) - tried << url + retry_after = error.is_a?(ApiError) ? error.retry_after : nil + return nil if retry_after && retry_after > config.max_retry_after + return retry_after if retry_after - begin - return yield(url) - rescue ConnectionError, TimeoutError => e - last_error = e - @rotator&.mark_failed(url) - end - end + cap = [config.max_retry_interval.to_f, config.retry_interval.to_f * (config.backoff_factor.to_f**attempt)].min + rand * cap + end - raise last_error || ConnectionError.new("All API endpoints unavailable") + def connection_for(base_url) + @mutex.synchronize { @connections[base_url] ||= build_connection(base_url) } end - # Build a Faraday connection for a given base URL def build_connection(base_url) Faraday.new(url: base_url, request: request_options, ssl: ssl_options) do |f| - f.request :json - f.request :retry, retry_options - f.response :json, content_type: /\bjson/ - f.response :logger, config.logger, {headers: true, bodies: false} if config.logger configure_adapter(f) end end @@ -120,26 +189,6 @@ def configure_adapter(builder) builder.adapter :net_http end - def default_headers - headers = { - "User-Agent" => config.user_agent, - "Accept" => "application/json" - } - headers["Authorization"] = config.api_key if config.api_key && !config.api_key.empty? - headers - end - - def retry_options - { - max: config.max_retries, - interval: config.retry_interval, - interval_randomness: 0.5, - backoff_factor: config.backoff_factor, - retry_statuses: RETRY_STATUSES, - methods: RETRY_METHODS - } - end - def request_options { open_timeout: config.open_timeout, @@ -152,6 +201,14 @@ def ssl_options {verify: config.ssl_verify} end + def log(method, base_url, path, outcome, started) + return unless config.logger + + ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round + config.logger.public_send(config.log_level || :info, + "[OctaSpace] #{method.upcase} #{base_url}#{path} -> #{outcome} (#{ms} ms)") + end + def invoke_hook(hook, *args) hook&.call(*args) end diff --git a/lib/octaspace/transport/persistent_transport.rb b/lib/octaspace/transport/persistent_transport.rb index 42fc213..a160956 100644 --- a/lib/octaspace/transport/persistent_transport.rb +++ b/lib/octaspace/transport/persistent_transport.rb @@ -8,80 +8,54 @@ module Transport # gem "faraday-net_http_persistent" # gem "connection_pool" # - # Inherits all retry/failover/hook logic from FaradayTransport. - # Overrides adapter configuration to use net_http_persistent with pooling. + # Inherits all retry/failover/hook logic from FaradayTransport; each base + # URL gets its own pool of persistent connections. # # @example # client = OctaSpace::Client.new( - # api_key: "key", + # api_key: "octa_ak_...", # keep_alive: true, # pool_size: ENV.fetch("RAILS_MAX_THREADS", 5).to_i # ) class PersistentTransport < FaradayTransport - def initialize(config) - @pools = {} - @pools_mutex = Mutex.new - super - end - # @return [Hash] connection pool diagnostics per URL def pool_stats - @pools_mutex.synchronize do - @pools.transform_values do |pool| + @mutex.synchronize do + @connections.transform_values do |pool| {size: pool.size, available: pool.available} end end end + # @return [Hash] transport diagnostics + def transport_stats + {mode: :persistent, urls: @urls, pools: pool_stats} + end + # Shut down all connection pools gracefully def shutdown - @pools_mutex.synchronize do - @pools.each_value { |pool| pool.shutdown { |_conn| nil } } - @pools.clear + @mutex.synchronize do + @connections.each_value { |pool| pool.shutdown { |conn| conn.close if conn.respond_to?(:close) } } + @connections.clear end end private - # Override: return a ConnectionPool instead of a bare Faraday connection + # Override: a ConnectionPool of persistent Faraday connections def build_connection(base_url) - pool_for(base_url) - end - - # Execute request via pool — `pool.with` yields a Faraday connection - def execute(conn_or_pool, method, path, params: {}, body: nil, headers: {}) - if conn_or_pool.is_a?(::ConnectionPool) - conn_or_pool.with { |conn| super(conn, method, path, params: params, body: body, headers: headers) } - else - super - end - end - - def pool_for(base_url) - @pools_mutex.synchronize do - @pools[base_url] ||= build_pool(base_url) - end - end - - def build_pool(base_url) - pool_size = config.pool_size - pool_timeout = config.pool_timeout - - ::ConnectionPool.new(size: pool_size, timeout: pool_timeout) do - build_persistent_connection(base_url) + ::ConnectionPool.new(size: config.pool_size, timeout: config.pool_timeout) do + Faraday.new(url: base_url, request: request_options, ssl: ssl_options) do |f| + f.adapter :net_http_persistent, pool_size: config.pool_size do |http| + http.idle_timeout = config.idle_timeout + end + end end end - def build_persistent_connection(base_url) - Faraday.new(url: base_url, request: request_options, ssl: ssl_options) do |f| - f.request :json - f.request :retry, retry_options - f.response :json, content_type: /\bjson/ - f.response :logger, config.logger, {headers: true, bodies: false} if config.logger - f.adapter :net_http_persistent, pool_size: config.pool_size do |http| - http.idle_timeout = config.idle_timeout - end - end + # Run the request on a connection checked out of the pool + def execute(pool, method, path, query:, payload:, headers:) + pool.with { |conn| super(conn, method, path, query: query, payload: payload, headers: headers) } end end end diff --git a/octaspace.gemspec b/octaspace.gemspec index fa012a9..47b0d0c 100644 --- a/octaspace.gemspec +++ b/octaspace.gemspec @@ -27,7 +27,6 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] spec.add_dependency "faraday", ">= 2.14.3", "< 3" - spec.add_dependency "faraday-retry", "~> 2.2" # Optional — persistent mode (users add to their own Gemfile): # gem "faraday-net_http_persistent" diff --git a/test/octaspace/configuration_test.rb b/test/octaspace/configuration_test.rb index e2f303c..1d48a2e 100644 --- a/test/octaspace/configuration_test.rb +++ b/test/octaspace/configuration_test.rb @@ -5,7 +5,7 @@ class OctaSpace::ConfigurationTest < Minitest::Test def test_defaults config = OctaSpace::Configuration.new - assert_equal "https://api.octa.space", config.base_url + assert_equal "https://api-dev.octa.space", config.base_url assert_equal 10, config.open_timeout assert_equal 30, config.read_timeout assert_equal false, config.keep_alive @@ -54,6 +54,23 @@ def test_user_agent_contains_version assert_includes config.user_agent, RUBY_VERSION end + def test_api_key_is_normalized + config = OctaSpace::Configuration.new + config.api_key = " Bearer octa_ak_x \n" + assert_equal "octa_ak_x", config.api_key + assert config.api_key? + + config.api_key = " " + assert_nil config.api_key + refute config.api_key? + end + + def test_urls_strip_trailing_slashes_and_blanks + config = OctaSpace::Configuration.new + config.base_urls = ["https://a.example/", "", " https://b.example// "] + assert_equal ["https://a.example", "https://b.example"], config.urls + end + def test_configure_block OctaSpace.configure do |c| c.api_key = "my_key" diff --git a/test/octaspace/response_test.rb b/test/octaspace/response_test.rb index 3024848..52297ca 100644 --- a/test/octaspace/response_test.rb +++ b/test/octaspace/response_test.rb @@ -4,12 +4,12 @@ class OctaSpace::ResponseTest < Minitest::Test def make_response(status:, body: {}, headers: {}) - faraday = Struct.new(:status, :headers, :body).new( - status, - {"content-type" => "application/json"}.merge(headers), - body + OctaSpace::Response.new( + status: status, + headers: Faraday::Utils::Headers.new({"content-type" => "application/json"}.merge(headers)), + body: JSON.generate(body), + data: body ) - OctaSpace::Response.new(faraday) end # --- Status predicates --- @@ -70,11 +70,34 @@ def test_retry_after_nil_when_missing # --- Data --- - def test_data_is_body + def test_data_is_parsed_body body = {"id" => 1, "name" => "test"} r = make_response(status: 200, body: body) assert_equal body, r.data - assert_equal body, r.body + assert_equal JSON.generate(body), r.body + end + + def test_retry_after_fraction_rounds_up + assert_equal 2, make_response(status: 429, headers: {"Retry-After" => "1.2"}).retry_after + end + + def test_retry_after_http_date + date = (Time.now + 30).httpdate + assert_in_delta 30, make_response(status: 429, headers: {"Retry-After" => date}).retry_after, 2 + end + + def test_retry_after_garbage_is_nil + assert_nil make_response(status: 429, headers: {"Retry-After" => "soon"}).retry_after + end + + def test_rate_limit_from_headers + r = make_response(status: 200, headers: {"RateLimit-Limit" => "300", "RateLimit-Remaining" => "12"}) + assert_equal OctaSpace::RateLimit.new(limit: 300, remaining: 12, reset: nil), r.rate_limit + end + + def test_rate_limit_nil_without_headers_or_with_garbage + assert_nil make_response(status: 200).rate_limit + assert_nil make_response(status: 200, headers: {"RateLimit-Limit" => "many"}).rate_limit end def test_data_array diff --git a/test/octaspace/transport/faraday_transport_test.rb b/test/octaspace/transport/faraday_transport_test.rb index 86fed61..6bcfdf5 100644 --- a/test/octaspace/transport/faraday_transport_test.rb +++ b/test/octaspace/transport/faraday_transport_test.rb @@ -1,218 +1,351 @@ # frozen_string_literal: true require "test_helper" +require "logger" +require "stringio" class OctaSpace::FaradayTransportTest < Minitest::Test BASE = StubHelpers::BASE_URL + KEY = "octa_ak_testkey" + JSON_HEADERS = {"Content-Type" => "application/json"}.freeze def setup super + @sleeps = [] @config = OctaSpace::Configuration.new - @config.api_key = "test_key" + @config.api_key = KEY @config.max_retries = 0 - @transport = OctaSpace::Transport::FaradayTransport.new(@config) end - # --- Basic HTTP verbs --- + def transport(config = @config) + OctaSpace::Transport::FaradayTransport.new(config, sleeper: ->(s) { @sleeps << s }) + end + + # --- Requests --- + + def test_get_parses_json_body + stub_request(:get, "#{BASE}/sessions").to_return(status: 200, body: '[{"uuid":"a"}]', headers: JSON_HEADERS) + response = transport.get("/sessions") - def test_get_returns_response - stub_request(:get, "#{BASE}/nodes") - .to_return(status: 200, body: "[]", headers: {"Content-Type" => "application/json"}) - response = @transport.get("/nodes") assert_instance_of OctaSpace::Response, response - assert response.success? + assert_equal [{"uuid" => "a"}], response.data + end + + def test_post_sends_json_body + stub_request(:post, "#{BASE}/services/compute") + .with(body: '{"node_number":"M95S-WZG8-7"}', 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"] end - def test_post_returns_response - stub_request(:post, "#{BASE}/idle-jobs") - .to_return(status: 201, body: '{"id":1}', headers: {"Content-Type" => "application/json"}) - response = @transport.post("/idle-jobs", body: {command: "sleep 1"}) - assert response.success? + def test_empty_body_on_204_and_202 + stub_request(:patch, "#{BASE}/hosting/nodes/X/prices").to_return(status: 204, body: "") + stub_request(:post, "#{BASE}/services/u/stop").to_return(status: 202, body: "") + + assert_nil transport.patch("/hosting/nodes/X/prices", body: {base: 100}).data + assert_nil transport.post("/services/u/stop").data end - def test_put_returns_response - stub_request(:put, "#{BASE}/nodes/1/prices") - .to_return(status: 200, body: "{}", headers: {"Content-Type" => "application/json"}) - response = @transport.put("/nodes/1/prices", body: {gpu_hour: 0.5}) - assert response.success? + def test_get_drops_nil_query_params + stub_request(:get, "#{BASE}/services/u/logs").with(query: {"tail" => "10"}).to_return(status: 200, body: "{}") + assert transport.get("/services/u/logs", params: {tail: 10, recent: nil}).success? end - def test_delete_returns_response - stub_request(:delete, "#{BASE}/idle-jobs/1") - .to_return(status: 200, body: "{}", headers: {"Content-Type" => "application/json"}) - response = @transport.delete("/idle-jobs/1") - assert response.success? + def test_base_url_path_prefix_is_kept + @config.base_url = "https://proxy.example/octa/" + stub_request(:get, "https://proxy.example/octa/network").to_return(status: 200, body: "{}") + + assert transport.get("/network", auth: :none).success? + end + + def test_invalid_json_on_success_raises_api_error + stub_request(:get, "#{BASE}/network").to_return(status: 200, body: "portal") + + error = assert_raises(OctaSpace::ApiError) { transport.get("/network", auth: :none) } + assert_includes error.message, "not valid JSON" + assert_equal 200, error.status + end + + # --- Authorization --- + + def test_sends_bearer_authorization + stub_request(:get, "#{BASE}/accounts/balance") + .with(headers: {"Authorization" => "Bearer #{KEY}"}) + .to_return(status: 200, body: "{}") + + assert transport.get("/accounts/balance").success? end - # --- Authorization header --- + def test_pasted_bearer_prefix_is_not_doubled + @config.api_key = " Bearer #{KEY} " + stub_request(:get, "#{BASE}/accounts/balance") + .with(headers: {"Authorization" => "Bearer #{KEY}"}) + .to_return(status: 200, body: "{}") - def test_sends_authorization_header - stub_request(:get, "#{BASE}/nodes") - .with(headers: {"Authorization" => "test_key"}) - .to_return(status: 200, body: "[]", headers: {"Content-Type" => "application/json"}) - @transport.get("/nodes") - # WebMock raises if header doesn't match — reaching here means it did - assert true + assert transport.get("/accounts/balance").success? + end + + def test_required_auth_without_key_raises_before_request + @config.api_key = nil + + error = assert_raises(OctaSpace::AuthenticationError) { transport.get("/sessions") } + assert_includes error.message, "api_key is required for GET /sessions" + assert_not_requested :get, "#{BASE}/sessions" + end + + def test_optional_auth_sends_key_when_present_and_skips_without + stub_request(:get, "#{BASE}/services/compute").to_return(status: 200, body: "[]") + + transport.get("/services/compute", auth: :optional) + assert_requested :get, "#{BASE}/services/compute", headers: {"Authorization" => "Bearer #{KEY}"} + + @config.api_key = nil + transport.get("/services/compute", auth: :optional) + assert_requested(:get, "#{BASE}/services/compute", times: 1) { |req| !req.headers.key?("Authorization") } + end + + def test_auth_none_never_sends_key + stub_request(:get, "#{BASE}/network").to_return(status: 200, body: "{}") + transport.get("/network", auth: :none) + + assert_requested(:get, "#{BASE}/network") { |req| !req.headers.key?("Authorization") } + end + + def test_401_with_legacy_key_adds_hint + @config.api_key = "a" * 64 + stub_request(:get, "#{BASE}/accounts/balance").to_return(status: 401, body: '{"message":"Invalid bearer token"}') + + error = assert_raises(OctaSpace::AuthenticationError) { transport.get("/accounts/balance") } + assert_match(/\AInvalid bearer token \(the next API expects a key starting with "octa_ak_"/, error.message) + end + + def test_401_with_new_key_has_no_hint + stub_request(:get, "#{BASE}/accounts/balance").to_return(status: 401, body: '{"message":"Invalid bearer token"}') + + error = assert_raises(OctaSpace::AuthenticationError) { transport.get("/accounts/balance") } + assert_equal "Invalid bearer token", error.message end # --- Error mapping --- - def test_raises_authentication_error_on_401 - stub_request(:get, "#{BASE}/accounts") - .to_return(status: 401, body: '{"error":"Unauthorized"}', - headers: {"Content-Type" => "application/json"}) - assert_raises(OctaSpace::AuthenticationError) { @transport.get("/accounts") } + { + 400 => OctaSpace::ValidationError, + 401 => OctaSpace::AuthenticationError, + 403 => OctaSpace::PermissionError, + 404 => OctaSpace::NotFoundError, + 409 => OctaSpace::ConflictError, + 422 => OctaSpace::ValidationError, + 429 => OctaSpace::RateLimitError, + 500 => OctaSpace::ServerError, + 502 => OctaSpace::BadGatewayError, + 503 => OctaSpace::ServiceUnavailableError, + 504 => OctaSpace::GatewayTimeoutError, + 418 => OctaSpace::ApiError + }.each do |status, klass| + define_method(:"test_status_#{status}_raises_#{klass.name.split("::").last}") do + stub_request(:get, "#{BASE}/sessions").to_return(status: status, body: '{"message":"boom"}') + + error = assert_raises(klass) { transport.get("/sessions") } + assert_equal status, error.status + assert_equal "boom", error.message + assert_equal({"message" => "boom"}, error.body) + end + end + + def test_error_message_falls_back_to_text_and_is_truncated + stub_request(:get, "#{BASE}/sessions").to_return(status: 502, body: "x" * 600) + + error = assert_raises(OctaSpace::BadGatewayError) { transport.get("/sessions") } + assert_equal 513, error.message.length + end + + def test_error_carries_retry_after_rate_limit_and_request_id + stub_request(:post, "#{BASE}/services/compute/u/restart").to_return( + status: 429, + body: '{"message":"Session was restarted too recently, retry in 288 seconds"}', + headers: {"Retry-After" => "288", "RateLimit-Limit" => "10", "RateLimit-Remaining" => "6", + "RateLimit-Reset" => "41", "X-Request-Id" => "req-1"} + ) + + error = assert_raises(OctaSpace::RateLimitError) { transport.post("/services/compute/u/restart") } + assert_equal 288, error.retry_after + assert_equal OctaSpace::RateLimit.new(limit: 10, remaining: 6, reset: 41), error.rate_limit + assert_equal "req-1", error.request_id end - def test_raises_permission_error_on_403 - stub_request(:get, "#{BASE}/accounts") - .to_return(status: 403, body: '{"error":"Forbidden"}', - headers: {"Content-Type" => "application/json"}) - assert_raises(OctaSpace::PermissionError) { @transport.get("/accounts") } + # --- Rate limit state --- + + def test_rate_limit_from_last_response + t = transport + assert_nil t.rate_limit + + stub_request(:get, "#{BASE}/sessions").to_return(status: 200, body: "[]", + headers: {"RateLimit-Limit" => "300", "RateLimit-Remaining" => "299", "RateLimit-Reset" => "60"}) + t.get("/sessions") + + assert_equal 299, t.rate_limit.remaining end - def test_raises_not_found_error_on_404 - stub_request(:get, "#{BASE}/nodes/99") - .to_return(status: 404, body: '{"error":"Not found"}', - headers: {"Content-Type" => "application/json"}) - assert_raises(OctaSpace::NotFoundError) { @transport.get("/nodes/99") } + # --- Retries --- + + def test_get_retries_on_503_with_backoff + @config.max_retries = 2 + stub_request(:get, "#{BASE}/network") + .to_return({status: 503, body: "{}"}, {status: 503, body: "{}"}, {status: 200, body: "{}"}) + + assert transport.get("/network", auth: :none).success? + assert_equal 2, @sleeps.size + assert(@sleeps.all? { |s| s.between?(0, @config.max_retry_interval) }) end - def test_raises_validation_error_on_422 - stub_request(:post, "#{BASE}/idle-jobs") - .to_return(status: 422, body: '{"error":"Invalid params"}', - headers: {"Content-Type" => "application/json"}) - assert_raises(OctaSpace::ValidationError) { @transport.post("/idle-jobs", body: {}) } + def test_get_retry_honours_retry_after + @config.max_retries = 1 + stub_request(:get, "#{BASE}/network") + .to_return({status: 429, body: "{}", headers: {"Retry-After" => "7"}}, {status: 200, body: "{}"}) + + transport.get("/network", auth: :none) + assert_equal [7], @sleeps end - def test_raises_rate_limit_error_on_429_with_retry_after - stub_request(:get, "#{BASE}/nodes") - .to_return(status: 429, body: "{}", - headers: {"Content-Type" => "application/json", "Retry-After" => "30"}) - error = assert_raises(OctaSpace::RateLimitError) { @transport.get("/nodes") } - assert_equal 30, error.retry_after + def test_long_retry_after_is_raised_not_waited + @config.max_retries = 3 + stub_request(:get, "#{BASE}/network").to_return(status: 429, body: "{}", headers: {"Retry-After" => "300"}) + + error = assert_raises(OctaSpace::RateLimitError) { transport.get("/network", auth: :none) } + assert_equal 300, error.retry_after + assert_empty @sleeps + assert_requested :get, "#{BASE}/network", times: 1 end - def test_retries_on_429_then_raises_after_exhaustion - # Enable retry with Retry-After: 0 so sleep is instant + def test_retries_stop_after_max_retries @config.max_retries = 1 - transport = OctaSpace::Transport::FaradayTransport.new(@config) - stub_request(:get, "#{BASE}/nodes") - .to_return(status: 429, body: "{}", - headers: {"Content-Type" => "application/json", "Retry-After" => "0"}) - assert_raises(OctaSpace::RateLimitError) { transport.get("/nodes") } - assert_requested :get, "#{BASE}/nodes", times: 2 + stub_request(:get, "#{BASE}/network").to_return(status: 429, body: "{}", headers: {"Retry-After" => "0"}) + + assert_raises(OctaSpace::RateLimitError) { transport.get("/network", auth: :none) } + assert_requested :get, "#{BASE}/network", times: 2 end - def test_raises_server_error_on_500 - stub_request(:get, "#{BASE}/nodes") - .to_return(status: 500, body: '{"error":"Internal Server Error"}', - headers: {"Content-Type" => "application/json"}) - assert_raises(OctaSpace::ServerError) { @transport.get("/nodes") } + def test_get_retries_network_errors + @config.max_retries = 1 + stub_request(:get, "#{BASE}/network") + .to_raise(Faraday::ConnectionFailed.new("refused")).then + .to_return(status: 200, body: "{}") + + assert transport.get("/network", auth: :none).success? end - def test_raises_bad_gateway_error_on_502 - stub_request(:get, "#{BASE}/nodes") - .to_return(status: 502, body: "{}", headers: {"Content-Type" => "application/json"}) - assert_raises(OctaSpace::BadGatewayError) { @transport.get("/nodes") } + def test_client_errors_are_not_retried + @config.max_retries = 2 + stub_request(:get, "#{BASE}/sessions").to_return(status: 404, body: "{}") + + assert_raises(OctaSpace::NotFoundError) { transport.get("/sessions") } + assert_requested :get, "#{BASE}/sessions", times: 1 end - def test_raises_service_unavailable_on_503 - stub_request(:get, "#{BASE}/nodes") - .to_return(status: 503, body: "{}", headers: {"Content-Type" => "application/json"}) - assert_raises(OctaSpace::ServiceUnavailableError) { @transport.get("/nodes") } + def test_post_is_never_retried + @config.max_retries = 2 + stub_request(:post, "#{BASE}/services/compute").to_return(status: 503, body: "{}") + stub_request(:post, "#{BASE}/services/compute/u/restart").to_raise(Faraday::TimeoutError.new("read timeout")) + + assert_raises(OctaSpace::ServiceUnavailableError) { transport.post("/services/compute", body: {}) } + assert_raises(OctaSpace::TimeoutError) { transport.post("/services/compute/u/restart") } + assert_requested :post, "#{BASE}/services/compute", times: 1 + assert_requested :post, "#{BASE}/services/compute/u/restart", times: 1 end - def test_raises_gateway_timeout_on_504 - stub_request(:get, "#{BASE}/nodes") - .to_return(status: 504, body: "{}", headers: {"Content-Type" => "application/json"}) - assert_raises(OctaSpace::GatewayTimeoutError) { @transport.get("/nodes") } + def test_idempotent_post_is_retried + @config.max_retries = 1 + stub_request(:post, "#{BASE}/services/u/stop") + .to_return({status: 502, body: ""}, {status: 202, body: '{"uuid":"u"}'}) + + assert_equal 202, transport.post("/services/u/stop", idempotent: true).status end # --- Network errors --- - def test_raises_connection_error_on_connection_failed - stub_request(:get, "#{BASE}/nodes").to_raise(Faraday::ConnectionFailed.new("Connection refused")) - assert_raises(OctaSpace::ConnectionError) { @transport.get("/nodes") } + def test_connection_failed_maps_to_connection_error + stub_request(:get, "#{BASE}/network").to_raise(Faraday::ConnectionFailed.new("Connection refused")) + assert_raises(OctaSpace::ConnectionError) { transport.get("/network", auth: :none) } end - def test_raises_timeout_error_on_timeout - stub_request(:get, "#{BASE}/nodes").to_raise(Faraday::TimeoutError.new("timeout")) - assert_raises(OctaSpace::TimeoutError) { @transport.get("/nodes") } + def test_timeout_maps_to_timeout_error + stub_request(:get, "#{BASE}/network").to_raise(Faraday::TimeoutError.new("read timeout")) + assert_raises(OctaSpace::TimeoutError) { transport.get("/network", auth: :none) } end - # --- Hooks --- + # --- Failover --- + + def test_get_fails_over_to_next_url + @config.base_urls = ["https://bad.example", "https://good.example"] + stub_request(:get, "https://bad.example/network").to_raise(Faraday::ConnectionFailed.new("refused")) + stub_request(:get, "https://good.example/network").to_return(status: 200, body: "{}") - def test_on_request_hook_is_called - called_with = nil - @config.on_request = ->(ctx) { called_with = ctx } - stub_request(:get, "#{BASE}/nodes") - .to_return(status: 200, body: "[]", headers: {"Content-Type" => "application/json"}) - @transport.get("/nodes") - assert_equal :get, called_with[:method] - assert_equal "/nodes", called_with[:path] + assert transport.get("/network", auth: :none).success? end - def test_on_response_hook_is_called - called_with = nil - @config.on_response = ->(resp) { called_with = resp } - stub_request(:get, "#{BASE}/nodes") - .to_return(status: 200, body: "[]", headers: {"Content-Type" => "application/json"}) - @transport.get("/nodes") - assert_instance_of OctaSpace::Response, called_with - assert_equal 200, called_with.status + def test_get_raises_when_all_urls_fail + @config.base_urls = ["https://bad1.example", "https://bad2.example"] + stub_request(:get, "https://bad1.example/network").to_raise(Faraday::ConnectionFailed.new("refused")) + stub_request(:get, "https://bad2.example/network").to_raise(Faraday::ConnectionFailed.new("refused")) + + assert_raises(OctaSpace::ConnectionError) { transport.get("/network", auth: :none) } end - # --- URL rotation / failover --- + def test_post_is_not_sent_to_a_second_url + @config.base_urls = ["https://bad.example", "https://good.example"] + stub_request(:post, "https://bad.example/services/compute").to_raise(Faraday::TimeoutError.new("read timeout")) + good = stub_request(:post, "https://good.example/services/compute").to_return(status: 201, body: "{}") - def test_url_rotation_with_multiple_base_urls - @config.base_urls = ["https://api.octa.space", "https://api2.octa.space"] - transport = OctaSpace::Transport::FaradayTransport.new(@config) + assert_raises(OctaSpace::TimeoutError) { transport.post("/services/compute", body: {}) } + assert_not_requested good + end - stub_request(:get, "https://api.octa.space/nodes") - .to_return(status: 200, body: "[]", headers: {"Content-Type" => "application/json"}) - stub_request(:get, "https://api2.octa.space/nodes") - .to_return(status: 200, body: "[]", headers: {"Content-Type" => "application/json"}) + def test_http_error_does_not_fail_over + @config.base_urls = ["https://a.example", "https://b.example"] + stub_request(:get, "https://a.example/sessions").to_return(status: 404, body: "{}") + other = stub_request(:get, "https://b.example/sessions").to_return(status: 200, body: "[]") - # Both calls should succeed (rotator picks one) - resp1 = transport.get("/nodes") - resp2 = transport.get("/nodes") - assert resp1.success? - assert resp2.success? + assert_raises(OctaSpace::NotFoundError) { transport.get("/sessions") } + assert_not_requested other end - def test_failover_to_second_url_on_connection_error - @config.base_urls = ["https://bad.octa.space", "https://api.octa.space"] - transport = OctaSpace::Transport::FaradayTransport.new(@config) + # --- Hooks and logging --- - stub_request(:get, "https://bad.octa.space/nodes") - .to_raise(Faraday::ConnectionFailed.new("refused")) - stub_request(:get, "https://api.octa.space/nodes") - .to_return(status: 200, body: "[]", headers: {"Content-Type" => "application/json"}) + def test_on_request_hook_gets_context_without_headers + seen = [] + @config.on_request = ->(ctx) { seen << ctx } + stub_request(:get, "#{BASE}/network").to_return(status: 200, body: "{}") + transport.get("/network", auth: :none) - response = transport.get("/nodes") - assert response.success? + assert_equal :get, seen.first[:method] + assert_equal "/network", seen.first[:path] + assert_equal "#{BASE}/network", seen.first[:url] + assert_equal 0, seen.first[:attempt] + refute seen.first.key?(:headers) end - def test_raises_connection_error_when_all_urls_fail - @config.base_urls = ["https://bad1.octa.space", "https://bad2.octa.space"] - transport = OctaSpace::Transport::FaradayTransport.new(@config) + def test_on_response_hook_gets_response + seen = nil + @config.on_response = ->(resp) { seen = resp } + stub_request(:get, "#{BASE}/network").to_return(status: 200, body: "{}") + transport.get("/network", auth: :none) - stub_request(:get, "https://bad1.octa.space/nodes") - .to_raise(Faraday::ConnectionFailed.new("refused")) - stub_request(:get, "https://bad2.octa.space/nodes") - .to_raise(Faraday::ConnectionFailed.new("refused")) - - assert_raises(OctaSpace::ConnectionError) { transport.get("/nodes") } + assert_equal 200, seen.status end - # --- Query params --- + def test_logger_writes_one_line_without_the_key + io = StringIO.new + @config.logger = Logger.new(io) + stub_request(:get, "#{BASE}/accounts/balance").to_return(status: 200, body: "{}") + transport.get("/accounts/balance") + + assert_match(%r{\[OctaSpace\] GET #{Regexp.escape(BASE)}/accounts/balance -> 200 \(\d+ ms\)}o, io.string) + refute_includes io.string, KEY + end - def test_get_passes_query_params - stub_request(:get, "#{BASE}/nodes") - .with(query: {"state" => "online", "limit" => "10"}) - .to_return(status: 200, body: "[]", headers: {"Content-Type" => "application/json"}) - response = @transport.get("/nodes", params: {state: "online", limit: 10}) - assert response.success? + def test_requires_a_base_url + @config.base_url = " " + assert_raises(OctaSpace::ConfigurationError) { transport } end end diff --git a/test/octaspace/transport/persistent_transport_test.rb b/test/octaspace/transport/persistent_transport_test.rb index 85b5f9d..3d80ca9 100644 --- a/test/octaspace/transport/persistent_transport_test.rb +++ b/test/octaspace/transport/persistent_transport_test.rb @@ -38,6 +38,10 @@ def test_post_returns_response assert response.success? end + def test_transport_stats_reports_persistent_mode + assert_equal :persistent, @transport.transport_stats[:mode] + end + def test_pool_stats_returns_hash stats = @transport.pool_stats assert_kind_of Hash, stats @@ -76,9 +80,8 @@ def test_raises_authentication_error_on_401 def test_sends_authorization_header stub_request(:get, "#{BASE}/nodes") - .with(headers: {"Authorization" => "test_key"}) + .with(headers: {"Authorization" => "Bearer test_key", "Connection" => "keep-alive"}) .to_return(status: 200, body: "[]", headers: {"Content-Type" => "application/json"}) - @transport.get("/nodes") - assert true + assert @transport.get("/nodes").success? end end diff --git a/test/support/stub_helpers.rb b/test/support/stub_helpers.rb index ff8911d..791e379 100644 --- a/test/support/stub_helpers.rb +++ b/test/support/stub_helpers.rb @@ -3,7 +3,7 @@ require "webmock/minitest" module StubHelpers - BASE_URL = "https://api.octa.space" + BASE_URL = OctaSpace::DEFAULT_BASE_URL # Stub a successful GET request # @param path [String] API path (e.g. "/nodes") From 1ef54a03d5fac25438b7d756ba49151ebd9deb63 Mon Sep 17 00:00:00 2001 From: secretpray Date: Tue, 3 Nov 2026 13:05:52 +0200 Subject: [PATCH 3/9] docs: translate the next-branch workflow guide to English --- docs/GIT_WORKFLOW_NEXT.md | 135 +++++++++++++++++++------------------- 1 file changed, 67 insertions(+), 68 deletions(-) diff --git a/docs/GIT_WORKFLOW_NEXT.md b/docs/GIT_WORKFLOW_NEXT.md index 11a260d..10d9513 100644 --- a/docs/GIT_WORKFLOW_NEXT.md +++ b/docs/GIT_WORKFLOW_NEXT.md @@ -1,87 +1,86 @@ -# Работа с веткой `next` +# Working with the `next` branch -`next` — ветка перехода SDK на новое API OctaSpace (стенд `https://api-dev.octa.space`). -Все правки под новое API идут PR-ами в `next`; слияние `next → main` — отдельным решением, -когда новое API выйдет в прод. `main` остаётся веткой текущего (legacy) SDK и хотфиксов. -Так же устроены Cube, go-sdk, js-sdk и octa-cli. +`next` is the branch that moves the SDK to the new OctaSpace API (test stand `https://api-dev.octa.space`). +All changes for the new API go to `next` as pull requests; merging `next → main` is a separate decision, +made when the new API reaches production. `main` stays the branch of the current (legacy) SDK and its hotfixes. +Cube, go-sdk, js-sdk and octa-cli work the same way. -На GitHub ветка по умолчанию — **`main`**. Поэтому PR, открытый без явной базы, -уходит в `main`, а не в `next`. +The default branch on GitHub is **`main`**. A PR opened without an explicit base therefore targets `main`, +not `next`. -## `main` и `next` — по шагам +## `main` and `next`, step by step -| Шаг | Работа с `main` | Работа с `next` | +| Step | Working with `main` | Working with `next` | |---|---|---| -| Обновить ветки | `git fetch origin` | `git fetch origin` — то же | -| Перейти на базовую ветку | `git switch main` | `git switch next` | -| Подтянуть свежий код | `git pull` | `git pull` — то же (`next` отслеживает `origin/next`) | -| Новая ветка задачи | `git switch -c feat/x` (с `main`) | `git switch -c feat/x origin/next` ← **главное отличие** | -| Коммит | `git add …` → `git commit` | то же | -| Первый пуш ветки | `git push -u origin feat/x` | то же (пушится ветка задачи, не `next`) | -| Подтянуть свежую базу | `git rebase origin/main` | `git rebase origin/next` | -| PR через `gh` | `gh pr create` | `gh pr create --base next` ← **обязательно** | -| PR на сайте | база `main` подставится сама | вручную сменить **base: main → next** | -| Диф ветки против базы | `git diff main...HEAD` | `git diff origin/next...HEAD` | -| После слияния PR | `git switch main && git pull` | `git switch next && git pull` | - -`git switch -c feat/x origin/next` создаёт ветку задачи прямо от удалённой `next`, -даже если сейчас вы стоите на `main` и локальной `next` у вас нет. Ветка сразу -отслеживает `origin/next`; первый `git push -u origin feat/x` переключает её на -собственную удалённую ветку. - -## Типовой цикл задачи +| Update branches | `git fetch origin` | `git fetch origin` (same) | +| Switch to the base branch | `git switch main` | `git switch next` | +| Pull the latest code | `git pull` | `git pull` (same; `next` tracks `origin/next`) | +| New task branch | `git switch -c feat/x` (from `main`) | `git switch -c feat/x origin/next` ← **the key difference** | +| Commit | `git add …` → `git commit` | same | +| First push of the branch | `git push -u origin feat/x` | same (push the task branch, not `next`) | +| Catch up with the base | `git rebase origin/main` | `git rebase origin/next` | +| PR with `gh` | `gh pr create` | `gh pr create --base next` ← **required** | +| PR on the website | base `main` is preselected | change **base: main → next** by hand | +| Branch diff against the base | `git diff main...HEAD` | `git diff origin/next...HEAD` | +| After the PR is merged | `git switch main && git pull` | `git switch next && git pull` | + +`git switch -c feat/x origin/next` creates the task branch straight from the remote `next`, +even if you are on `main` and have no local `next`. The branch tracks `origin/next` at first; +the first `git push -u origin feat/x` switches it to its own remote branch. + +## Typical task cycle ```bash git fetch origin -git switch -c feat/my-task origin/next # от свежей next, даже если сейчас стоите на main -# ... правки ... +git switch -c feat/my-task origin/next # from a fresh next, even when you are on main +# ... changes ... bundle exec rake test && bundle exec standardrb git add -A && git commit -m "…" git push -u origin feat/my-task -gh pr create --base next # без --base PR откроется в main +gh pr create --base next # without --base the PR opens against main ``` -Если пока вы работали, в `next` что-то влили — обновить ветку перед PR: +If something was merged into `next` while you were working, update the branch before the PR: ```bash git fetch origin git rebase origin/next -git push --force-with-lease # после rebase обычный push откажет +git push --force-with-lease # a plain push is refused after a rebase ``` -## Зависимые PR (стек) +## Dependent PRs (a stack) -Если задача B строится на ещё не влитой задаче A, ветку B создают от A, а PR открывают -с базой A: `gh pr create --base feat/a`. Тогда в PR видна только разница B. +When task B builds on task A that is not merged yet, create branch B from A and open the PR +with base A: `gh pr create --base feat/a`. The PR then shows only B's changes. -⚠️ **Не удаляйте ветку A при слиянии** (`gh pr merge --delete-branch` или кнопка -«Delete branch»), пока на неё смотрит PR B: GitHub **закроет** PR B, а не перенацелит его. -Сливать стек по одному PR, в таком порядке: +⚠️ **Do not delete branch A when merging it** (`gh pr merge --delete-branch` or the +"Delete branch" button) while PR B points at it: GitHub **closes** PR B instead of retargeting it. +Merge the stack one PR at a time, in this order: ```bash -gh pr merge
--merge # без --delete-branch -gh pr edit --base next # перенацелить следующий PR -git push origin --delete feat/a # теперь ветку A можно удалить +gh pr merge --merge # without --delete-branch +gh pr edit --base next # retarget the next PR +git push origin --delete feat/a # now branch A can be deleted ``` -Если PR B всё же закрылся: восстановить ветку A на её последнем коммите -(`git push origin :refs/heads/feat/a`), затем `gh pr reopen ` и +If PR B was closed anyway: restore branch A at its last commit +(`git push origin :refs/heads/feat/a`), then `gh pr reopen ` and `gh pr edit --base next`. -## Ошибки, которые легко сделать - -1. **PR открылся в `main`.** Забыли `--base next` или не сменили base на сайте — - в PR вся разница `next` против `main`. - Исправить: `gh pr edit --base next`. -2. **Ветка создана от `main`.** `git switch -c feat/x`, стоя на `main`, даёт ветку - от `main`, и PR в `next` притащит лишние коммиты. Поэтому базу всегда указывать - явно (`origin/next`), а не полагаться на текущую ветку. - Исправить: `git rebase --onto origin/next origin/main feat/x`. -3. **Прямой push в `next`.** Пушить только ветки задач; в `next` — только через PR, - как и в `main`. -4. **Хотфиксы в `main` не доходят до `next`.** Срочные исправления legacy-SDK идут - веткой от `main` с PR в `main`. Чтобы они не потерялись, `main` периодически - вливается в `next` отдельным PR: +## Easy mistakes + +1. **The PR opened against `main`.** `--base next` was forgotten or the base was not changed on the + website, so the PR shows the whole difference between `next` and `main`. + Fix: `gh pr edit --base next`. +2. **The branch was created from `main`.** `git switch -c feat/x` while on `main` branches off `main`, + and a PR into `next` drags in extra commits. Always name the base explicitly (`origin/next`) + instead of relying on the current branch. + Fix: `git rebase --onto origin/next origin/main feat/x`. +3. **Pushing straight to `next`.** Push only task branches; changes reach `next` through PRs, + just like `main`. +4. **Hotfixes on `main` never reach `next`.** Urgent fixes to the legacy SDK go in a branch from + `main` with a PR into `main`. So that they are not lost, merge `main` into `next` from time to time + in a separate PR: ```bash git switch -c chore/sync-main-into-next origin/next @@ -90,18 +89,18 @@ git push origin --delete feat/a # теперь ветку A можно gh pr create --base next ``` -5. **Устаревшая локальная `next`.** Она обновляется только по `git pull`. Для новой - ветки и rebase надёжнее брать `origin/next` сразу после `git fetch`. +5. **A stale local `next`.** It only moves on `git pull`. For new branches and rebases it is safer + to use `origin/next` right after `git fetch`. -## CI и релизы +## CI and releases -- CI (`.github/workflows/ci.yml`) запускается на push в `main` и `next` и на любой PR, включая PR в стеке. -- Dependabot открывает PR обновлений в `next` (`target-branch` в `.github/dependabot.yml`). - После слияния `next → main` вернуть `target-branch: main`. -- Гем из `next` в RubyGems не публикуется, пока новое API не выйдет в прод. +- CI (`.github/workflows/ci.yml`) runs on pushes to `main` and `next` and on every PR, stacked ones included. +- Dependabot opens update PRs against `next` (`target-branch` in `.github/dependabot.yml`). + After `next` is merged into `main`, set `target-branch: main` again. +- The gem is not published to RubyGems from `next` until the new API reaches production. -## Если `next` — надолго +## If `next` lives for a long time -Можно переключить ветку по умолчанию на GitHub (Settings → Branches) на `next`: -PR будут открываться в неё сами, и ошибка 1 исчезнет. Это меняет базу по умолчанию -для всей команды, поэтому решается командой, а не одним разработчиком. +The default branch on GitHub (Settings → Branches) can be switched to `next`: PRs then target it +by default and mistake 1 goes away. This changes the default base for the whole team, so it is a +team decision, not one developer's. From bf1cdd51e9b4cabde742f5c6ca5e9ab45297f5e6 Mon Sep 17 00:00:00 2001 From: secretpray Date: Tue, 3 Nov 2026 13:06:25 +0200 Subject: [PATCH 4/9] feat!: compute and render resources for the next API - client.compute: list (GET /services/compute, key optional), start (object -> 201, returns the uuid), start_batch (array -> 200, per-item BatchResult), restart (POST .../restart -> 202; no params restarts with the original ones, params replace all of them) - client.render: list, start(node_number:), start_batch - typed read-only models (OctaSpace::Model) for ComputeMachine, RenderMachine, Gpu, Reliability and BatchResult; unknown fields are kept and reachable via [] / to_h - listings repair "null" strings and string GPU integers so one misreporting node does not break the listing - request bodies carry only the parameters that were set (ssh/fm stay tri-state), unknown parameters raise ArgumentError; batch items get their position as id, duplicate ids raise ArgumentError - removed: services.mr, services.vpn, services.render, ProvisionRejectedError, PayloadHelpers.normalize_marketplace_bandwidth --- lib/octaspace.rb | 5 + lib/octaspace/client.rb | 4 +- lib/octaspace/errors.rb | 11 -- lib/octaspace/json_null.rb | 61 +++++++ lib/octaspace/model.rb | 102 +++++++++++ lib/octaspace/payload_helpers.rb | 20 -- lib/octaspace/resources/base.rb | 43 ++++- lib/octaspace/resources/compute.rb | 101 +++++++++++ lib/octaspace/resources/render.rb | 46 +++++ lib/octaspace/resources/services.rb | 24 +-- .../resources/services/machine_rental.rb | 78 -------- lib/octaspace/resources/services/render.rb | 30 --- lib/octaspace/resources/services/vpn.rb | 30 --- lib/octaspace/types/machines.rb | 87 +++++++++ test/fixtures/next/batch.json | 17 ++ test/fixtures/next/services_compute.json | 167 +++++++++++++++++ test/fixtures/next/services_render.json | 94 ++++++++++ test/fixtures/services/mr/index.json | 21 --- test/fixtures/services/render/index.json | 13 -- test/fixtures/services/vpn/index.json | 12 -- test/octaspace/json_null_test.rb | 21 +++ test/octaspace/model_test.rb | 60 ++++++ test/octaspace/payload_helpers_test.rb | 12 -- test/octaspace/resources/compute_test.rb | 171 ++++++++++++++++++ test/octaspace/resources/render_test.rb | 54 ++++++ test/octaspace/resources/services_test.rb | 126 +------------ 26 files changed, 1031 insertions(+), 379 deletions(-) create mode 100644 lib/octaspace/json_null.rb create mode 100644 lib/octaspace/model.rb create mode 100644 lib/octaspace/resources/compute.rb create mode 100644 lib/octaspace/resources/render.rb delete mode 100644 lib/octaspace/resources/services/machine_rental.rb delete mode 100644 lib/octaspace/resources/services/render.rb delete mode 100644 lib/octaspace/resources/services/vpn.rb create mode 100644 lib/octaspace/types/machines.rb create mode 100644 test/fixtures/next/batch.json create mode 100644 test/fixtures/next/services_compute.json create mode 100644 test/fixtures/next/services_render.json delete mode 100644 test/fixtures/services/mr/index.json delete mode 100644 test/fixtures/services/render/index.json delete mode 100644 test/fixtures/services/vpn/index.json create mode 100644 test/octaspace/json_null_test.rb create mode 100644 test/octaspace/model_test.rb create mode 100644 test/octaspace/resources/compute_test.rb create mode 100644 test/octaspace/resources/render_test.rb diff --git a/lib/octaspace.rb b/lib/octaspace.rb index ea61c7d..f95b98c 100644 --- a/lib/octaspace.rb +++ b/lib/octaspace.rb @@ -4,6 +4,8 @@ require "octaspace/errors" require "octaspace/payload_helpers" require "octaspace/response" +require "octaspace/json_null" +require "octaspace/model" require "octaspace/configuration" require "octaspace/middleware/url_rotator" require "octaspace/transport/base" @@ -12,6 +14,7 @@ require "octaspace/types/balance" require "octaspace/types/node" require "octaspace/types/session" +require "octaspace/types/machines" require "octaspace/resources/base" require "octaspace/resources/accounts" require "octaspace/resources/nodes" @@ -19,6 +22,8 @@ require "octaspace/resources/apps" require "octaspace/resources/network" require "octaspace/resources/services" +require "octaspace/resources/compute" +require "octaspace/resources/render" require "octaspace/resources/idle_jobs" require "octaspace/client" diff --git a/lib/octaspace/client.rb b/lib/octaspace/client.rb index 63809ee..3388d30 100644 --- a/lib/octaspace/client.rb +++ b/lib/octaspace/client.rb @@ -36,7 +36,7 @@ module OctaSpace # ) class Client attr_reader :accounts, :nodes, :sessions, :apps, - :network, :services, :idle_jobs + :network, :services, :idle_jobs, :compute, :render # @param api_key [String, nil] API key for authentication (optional for public endpoints) # @param opts [Hash] Per-instance configuration overrides. @@ -51,6 +51,8 @@ def initialize(api_key: nil, transport: nil, **opts) @network = Resources::Network.new(@transport) @services = Resources::Services.new(@transport) @idle_jobs = Resources::IdleJobs.new(@transport) + @compute = Resources::Compute.new(@transport) + @render = Resources::Render.new(@transport) end # Shut down persistent connections (only relevant in keep_alive mode) diff --git a/lib/octaspace/errors.rb b/lib/octaspace/errors.rb index 6f4bef7..d4405b2 100644 --- a/lib/octaspace/errors.rb +++ b/lib/octaspace/errors.rb @@ -82,17 +82,6 @@ class ServiceUnavailableError < ServerError; end class GatewayTimeoutError < ServerError; end # 504 - # Legacy batch-style rejection of services.mr.create (removed with that resource) - class ProvisionRejectedError < Error - attr_reader :rejections - - def initialize(message = nil, response: nil, rejections: []) - @rejections = Array(rejections) - reason = @rejections.filter_map { |item| item["reason"] if item.is_a?(Hash) }.first - super(message || (reason ? "Provision request rejected: #{reason}" : "Provision request rejected"), response: response) - end - end - # HTTP status code → exception class mapping STATUS_ERRORS = { 400 => ValidationError, diff --git a/lib/octaspace/json_null.rb b/lib/octaspace/json_null.rb new file mode 100644 index 0000000..04e59b5 --- /dev/null +++ b/lib/octaspace/json_null.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module OctaSpace + # Repairs for values the next API sometimes sends with the wrong JSON type. + # + # A missing value can arrive as the string "null" instead of JSON null: in + # GPU statistics (price percentiles, TFLOPS) and in what nodes report about + # their hardware (+mem_clock+, +power_limit+, +fan_speed+, …). Integer GPU + # fields can also arrive as strings ("", "390"). Without these repairs one + # misreporting node would break a whole listing. + # + # @api private + module JsonNull + module_function + + # Replaces every "null" string value (not object keys) with nil, in place. + # Must not be applied where "null" can be real data, such as a session's + # environment variables. + # @return [Object] the same value + def null_strings!(value) + case value + when Array + value.map! { |item| (item == "null") ? nil : null_strings!(item) } + when Hash + value.each { |key, item| value[key] = (item == "null") ? nil : null_strings!(item) } + end + value + end + + # @return [Integer, Float, nil] a numeric value as is; a numeric string as + # a number; anything else (blank, "null", text) as nil + def number(value) + case value + when Numeric, nil then value + when String + text = value.strip + if text.match?(/\A-?\d+\z/) + text.to_i + elsif text.match?(/\A-?\d+\.\d+\z/) + text.to_f + end + end + end + + # Normalizes integer fields of each GPU object of a machine listing, in place. + # @param machines [Array] + # @param fields [Array] + def gpu_numbers!(machines, fields) + Array(machines).each do |machine| + next unless machine.is_a?(Hash) + + Array(machine["gpus"]).each do |gpu| + next unless gpu.is_a?(Hash) + + fields.each { |field| gpu[field] = number(gpu[field]) if gpu[field].is_a?(String) } + end + end + machines + end + end +end diff --git a/lib/octaspace/model.rb b/lib/octaspace/model.rb new file mode 100644 index 0000000..641cc4b --- /dev/null +++ b/lib/octaspace/model.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +module OctaSpace + # Read-only view over a JSON object returned by the API. + # + # Declared attributes get reader methods; nested objects and arrays of + # objects are wrapped in their own model classes. Fields the SDK does not + # know yet are kept and reachable with {#[]} and {#to_h}, so a new field in + # the API (it is in active development) never breaks parsing. + # + # @example + # machine = client.compute.list.first + # machine.node_number # => "M95S-WZG8-7" + # machine.gpus.first.model # => "NVIDIA GeForce RTX 3090" + # machine[:new_field] # => value of a field the SDK has no reader for + # machine.to_h # => the JSON object as parsed + class Model + class << self + # Declares attributes with reader methods. + # @param names [Array] + # @param model [Class, nil] wrap a nested object (or each element of an array) in this model + # @param many [Boolean] the value is an array of objects; nil reads as [] + def attribute(*names, model: nil, many: false) + names.each do |name| + key = name.to_s + attribute_names << name + if model + define_method(name) { nested(key, model, many) } + else + define_method(name) { @data[key] } + end + end + end + + # @return [Array] declared attribute names + def attribute_names + @attribute_names ||= [] + end + + def inherited(subclass) + super + subclass.instance_variable_set(:@attribute_names, attribute_names.dup) + end + + # Wraps a JSON object; anything else (nil, a scalar) is returned as is. + def wrap(value) + value.is_a?(Hash) ? new(value) : value + end + + # Wraps each JSON object of an array; nil becomes an empty array. + def wrap_all(values) + Array(values).map { |value| wrap(value) } + end + end + + # @param data [Hash] JSON object with string keys + def initialize(data = {}) + @data = data.to_h.transform_keys(&:to_s) + @nested = {} + end + + # Value of any field, including ones without a reader + # @param key [String, Symbol] + def [](key) = @data[key.to_s] + + # @param key [String, Symbol] + # @return [Boolean] whether the API sent this field (it may be null) + def key?(key) = @data.key?(key.to_s) + + # @return [Hash] the JSON object as parsed (string keys, nested values unwrapped) + def to_h = @data + + # Pattern matching support: +in {node_number:, base_price:}+ + def deconstruct_keys(keys) + (keys || @data.keys.map(&:to_sym)).to_h { |key| [key, respond_to?(key) ? public_send(key) : @data[key.to_s]] } + end + + def ==(other) + other.instance_of?(self.class) && other.to_h == to_h + end + alias_method :eql?, :== + + def hash = [self.class, @data].hash + + def inspect + shown = self.class.attribute_names.filter_map do |name| + "#{name}=#{@data[name.to_s].inspect}" if @data.key?(name.to_s) && !@data[name.to_s].is_a?(Enumerable) + end + "#<#{self.class.name} #{shown.join(" ")}>" + end + alias_method :to_s, :inspect + + private + + def nested(key, model, many) + return @nested[key] if @nested.key?(key) + + value = @data[key] + @nested[key] = (many || value.is_a?(Array)) ? model.wrap_all(value) : model.wrap(value) + end + end +end diff --git a/lib/octaspace/payload_helpers.rb b/lib/octaspace/payload_helpers.rb index 7d120bf..b944b88 100644 --- a/lib/octaspace/payload_helpers.rb +++ b/lib/octaspace/payload_helpers.rb @@ -19,26 +19,6 @@ def parse_port_list(value) end end - def normalize_marketplace_bandwidth(value) - numeric = - case value - when nil - return nil - when Numeric - value.to_f - when String - Float(value) - else - return value - end - - return numeric unless numeric > 100_000 - - numeric / 125_000.0 - rescue ArgumentError, TypeError - value - end - def parse_stringified_port_list(value) stripped = value.strip return [] if stripped.empty? diff --git a/lib/octaspace/resources/base.rb b/lib/octaspace/resources/base.rb index ba5b8e8..abe0ce1 100644 --- a/lib/octaspace/resources/base.rb +++ b/lib/octaspace/resources/base.rb @@ -7,8 +7,11 @@ module Resources # Base class for all API resource groups # # Delegates HTTP methods to the transport layer and provides - # a clean DSL for subclasses. + # helpers shared by the resources. class Base + # Integer fields of a listing's GPU objects that nodes may send as strings + GPU_INTEGER_FIELDS = %w[mem_total_mb pcie_link_gen pcie_link_width core_clock mem_clock power_limit].freeze + # @param transport [OctaSpace::Transport::Base] def initialize(transport) @transport = transport @@ -20,9 +23,7 @@ def initialize(transport) def get(path, **opts) = transport.get(path, **opts) def post(path, **opts) = transport.post(path, **opts) - def put(path, **opts) = transport.put(path, **opts) def patch(path, **opts) = transport.patch(path, **opts) - def delete(path, **opts) = transport.delete(path, **opts) # Encode a single path segment to prevent path traversal # @param segment [String, Integer] @@ -30,6 +31,42 @@ def delete(path, **opts) = transport.delete(path, **opts) def encode(segment) URI.encode_www_form_component(segment.to_s) end + + # Keeps only the given keys whose value is not nil, so a request never + # carries fields the caller did not set: the API refuses unknown fields, + # and treats an absent ssh/fm differently from false. + def compact_params(params, allowed) + params = params.to_h.transform_keys(&:to_sym) + unknown = params.keys - allowed + raise ArgumentError, "unknown parameter(s): #{unknown.join(", ")}" if unknown.any? + + params.compact + end + + # Gives every batch item an id: its own, or its position in the array. + # Raises ArgumentError when two items end up with the same id (their + # results could not be told apart, and each started session costs money). + def batch_items(items, allowed) + raise ArgumentError, "a batch needs at least one item" if Array(items).empty? + + seen = {} + Array(items).each_with_index.map do |item, index| + raise ArgumentError, "batch item #{index} must be a Hash" unless item.is_a?(Hash) + + params = item.to_h.transform_keys(&:to_sym) + id = params.key?(:id) ? params.delete(:id) : index + raise ArgumentError, "duplicate batch id #{id.inspect}: give every item a unique id, or none to all of them" if seen.key?(id) + + seen[id] = true + {id: id, **compact_params(params, allowed)} + end + end + + # Machine listings: "null" strings become nil and string GPU integers + # become numbers, so one misreporting node does not break the listing. + def normalize_machines(data) + JsonNull.gpu_numbers!(JsonNull.null_strings!(Array(data)), GPU_INTEGER_FIELDS) + end end end end diff --git a/lib/octaspace/resources/compute.rb b/lib/octaspace/resources/compute.rb new file mode 100644 index 0000000..0b1a168 --- /dev/null +++ b/lib/octaspace/resources/compute.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +module OctaSpace + module Resources + # Compute machines: listing, start (single and batch) and restart. + # + # @example + # machine = client.compute.list.min_by(&:base_price) + # uuid = client.compute.start(node_number: machine.node_number, image: "ubuntu:24.04", + # disk_size: 10, ssh: true) + # client.sessions.wait_ready(uuid) + class Compute < Base + # Parameters that {#restart} replaces + RESTART_PARAMS = %i[disk_size envs ports http_ports entrypoint args ssh fm startup_script].freeze + + # Parameters of {#start} + START_PARAMS = (%i[node_number image app] + RESTART_PARAMS).freeze + + # Machines available for rent. + # GET /services/compute + # + # Works without an API key, but the anonymous listing is cached for up + # to an hour; with a key it is at most 10 seconds old. + # @return [Array] + def list + Types::ComputeMachine.wrap_all(normalize_machines(get("/services/compute", auth: :optional).data)) + end + + # Starts one session. + # POST /services/compute (object → 201) + # + # Validation and business errors ("Insufficient balance", "Node not + # available", …) raise {OctaSpace::ValidationError}. Never retried + # automatically: a start costs money. + # + # @param node_number [String] node number from the listing, e.g. "M95S-WZG8-7" + # (case, dashes and spaces are ignored by the API) + # @param image [String] container image, e.g. "ubuntu:24.04" + # @param app [String, nil] application uuid + # @param disk_size [Integer, nil] disk, GB (API default 1) + # @param envs [Hash{String=>String}, nil] up to 64 variables, values up to 4 KiB + # @param ports [Array, nil] TCP/UDP ports to expose (0 and 22 are ignored) + # @param http_ports [Array, nil] HTTP ports to expose (7681, 7682 are reserved) + # @param entrypoint [String, nil] replaces the image's ENTRYPOINT (one line, up to 4 KiB) + # @param args [String, nil] replaces the image's CMD (one line, up to 4 KiB) + # @param ssh [Boolean, nil] web terminal and SSH access; nil = the app's setting + # @param fm [Boolean, nil] web file manager (no login: anyone with the URL can use it); + # nil = the app's setting + # @param startup_script [String, nil] run as root in the background once the container is up + # @return [String] the session uuid + def start(**params) + missing = %i[node_number image].reject { |key| params.key?(key) } + raise ArgumentError, "missing keyword(s): #{missing.join(", ")}" if missing.any? + + post("/services/compute", body: compact_params(params, START_PARAMS)).data.fetch("uuid") + end + + # Starts several sessions at once. + # POST /services/compute (array → 200) + # + # Each item is validated on its own and reported in request order; a + # failed item does not fail the batch. Items without an +:id+ get their + # position in the array; ids may be integers or strings. + # + # @param items [Array] {#start} parameters, each with an optional +:id+ + # @return [Array] + def start_batch(items) + Types::BatchResult.wrap_all(post("/services/compute", body: batch_items(items, START_PARAMS)).data) + end + + # Recreates the session's container on the same node, keeping the rent, + # uuid and prices. + # POST /services/compute/{uuid}/restart (→ 202) + # + # Follow the progress with +client.sessions.get(uuid)+; if the new + # parameters cannot be applied, the session stops. + # + # - Without parameters: restarts with the original parameters. + # - With parameters: *replaces all of them*. Every omitted parameter gets + # a fixed default, not its current value: +disk_size+ 1 GB, + # +entrypoint+/+args+ from the image, no +startup_script+, no +envs+, + # no +ports+/+http_ports+, +ssh+/+fm+ from the app (off without one). + # The API does not report a session's launch parameters, so pass the + # full set you want, +disk_size+ included. + # + # Raises {OctaSpace::ConflictError} (409) while the session is still + # starting or restarting, and {OctaSpace::RateLimitError} (429) when it is + # restarted more often than once per 5 minutes or 6 times per hour + # (+retry_after+ says how long to wait). Never retried automatically. + # + # @param uuid [String] + # @param params [Hash] see {#start}, without node_number, image and app + # @return [String] the session uuid + def restart(uuid, **params) + body = compact_params(params, RESTART_PARAMS) + response = post("/services/compute/#{encode(uuid)}/restart", body: body.empty? ? nil : body) + response.data.is_a?(Hash) ? response.data.fetch("uuid", uuid) : uuid + end + end + end +end diff --git a/lib/octaspace/resources/render.rb b/lib/octaspace/resources/render.rb new file mode 100644 index 0000000..7b8f04a --- /dev/null +++ b/lib/octaspace/resources/render.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module OctaSpace + module Resources + # Render nodes: listing and start (single and batch). + # + # @example + # node = client.render.list.max_by(&:blender_score) + # uuid = client.render.start(node_number: node.node_number) + class Render < Base + START_PARAMS = %i[node_number].freeze + + # Nodes available for render. + # GET /services/render + # + # Works without an API key; with a key the listing is fresher. + # @return [Array] + def list + Types::RenderMachine.wrap_all(normalize_machines(get("/services/render", auth: :optional).data)) + end + + # Rents a node as a render node, billed by the hour until stopped. + # POST /services/render (object → 201) + # + # While render is temporarily unavailable this raises + # {OctaSpace::ServiceUnavailableError} (503). Never retried automatically. + # @param node_number [String] + # @return [String] the session uuid + def start(node_number:) + post("/services/render", body: {node_number: node_number}).data.fetch("uuid") + end + + # Rents several render nodes at once. + # POST /services/render (array → 200) + # + # Unlike {#start}, a temporarily unavailable service is reported per item + # (+status: 1+, +reason: "Service is temporarily unavailable"+), not as + # an HTTP 503. + # @param items [Array] each +{node_number:}+ with an optional +:id+ + # @return [Array] + def start_batch(items) + Types::BatchResult.wrap_all(post("/services/render", body: batch_items(items, START_PARAMS)).data) + end + end + end +end diff --git a/lib/octaspace/resources/services.rb b/lib/octaspace/resources/services.rb index a8ec9fb..5ba8cf9 100644 --- a/lib/octaspace/resources/services.rb +++ b/lib/octaspace/resources/services.rb @@ -2,27 +2,8 @@ module OctaSpace module Resources - # Services namespace — aggregates MR, VPN, Render subresources - # and provides the session proxy pattern - # - # @example - # client.services.mr.list - # client.services.vpn.create(node_id: 123) - # client.services.render.create(node_id: 456, disk_size: 100) - # - # # Session proxy pattern - # client.services.session("uuid-123").info - # client.services.session("uuid-123").stop(score: 5) + # Legacy session proxy: client.services.session("uuid").info / logs / stop class Services < Base - attr_reader :mr, :vpn, :render - - def initialize(transport) - super - @mr = Services::MachineRental.new(transport) - @vpn = Services::Vpn.new(transport) - @render = Services::Render.new(transport) - end - # Return a proxy object for operations on a specific session # @param uuid [String] session UUID # @return [OctaSpace::Resources::Services::SessionProxy] @@ -34,6 +15,3 @@ def session(uuid) end require_relative "services/session_proxy" -require_relative "services/machine_rental" -require_relative "services/vpn" -require_relative "services/render" diff --git a/lib/octaspace/resources/services/machine_rental.rb b/lib/octaspace/resources/services/machine_rental.rb deleted file mode 100644 index 3b9e54f..0000000 --- a/lib/octaspace/resources/services/machine_rental.rb +++ /dev/null @@ -1,78 +0,0 @@ -# frozen_string_literal: true - -module OctaSpace - module Resources - class Services - # Machine Rental (MR) service endpoints - # - # @example - # client.services.mr.list - # client.services.mr.create( - # node_id: 123, - # disk_size: 10, - # image: "ubuntu:24.04", - # app: "249b4cb3-3db1-4c06-98a4-772ba88cd81c" - # ) - class MachineRental < Base - # List available marketplace machines for rent - # GET /services/mr - # @param params [Hash] optional filter params - # @return [OctaSpace::Response] - def list(**params) - get("/services/mr", params:, auth: :optional) - end - - # Create (start) a machine rental - # POST /services/mr - # @param attrs [Hash] rental parameters - # @return [OctaSpace::Response] - def create(**attrs) - item = { - id: 0, - node_id: attrs.fetch(:node_id), - disk_size: attrs.fetch(:disk_size), - image: attrs.fetch(:image), - app: attrs[:app].to_s, - envs: attrs[:envs] || {}, - ports: attrs[:ports] || [], - http_ports: attrs[:http_ports] || [], - start_command: attrs[:start_command].to_s, - entrypoint: attrs[:entrypoint].to_s - } - - item[:organization_id] = attrs[:organization_id] if attrs.key?(:organization_id) - item[:project_id] = attrs[:project_id] if attrs.key?(:project_id) - - response = post("/services/mr", body: [item]) - raise_if_rejected!(response) - response - end - - private - - def raise_if_rejected!(response) - rejections = extract_rejections(response.data) - return if rejections.empty? - - raise OctaSpace::ProvisionRejectedError.new(response: response, rejections: rejections) - end - - def extract_rejections(data) - return [] unless data.is_a?(Array) - - data.filter_map do |item| - next unless item.is_a?(Hash) - - reason = item["reason"] || item[:reason] - status = item["status"] || item[:status] - uuid = item["uuid"] || item[:uuid] - next if uuid - next unless reason || status.to_i.positive? - - item - end - end - end - end - end -end diff --git a/lib/octaspace/resources/services/render.rb b/lib/octaspace/resources/services/render.rb deleted file mode 100644 index e8a39e6..0000000 --- a/lib/octaspace/resources/services/render.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -module OctaSpace - module Resources - class Services - # Render service endpoints - # - # @example - # client.services.render.list - # client.services.render.create(node_id: 123, disk_size: 100) - class Render < Base - # List render jobs - # GET /services/render - # @param params [Hash] optional filter params - # @return [OctaSpace::Response] - def list(**params) - get("/services/render", params:, auth: :optional) - end - - # Create (start) a render job - # POST /services/render - # @param attrs [Hash] render job parameters - # @return [OctaSpace::Response] - def create(**attrs) - post("/services/render", body: attrs) - end - end - end - end -end diff --git a/lib/octaspace/resources/services/vpn.rb b/lib/octaspace/resources/services/vpn.rb deleted file mode 100644 index 00d114c..0000000 --- a/lib/octaspace/resources/services/vpn.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -module OctaSpace - module Resources - class Services - # VPN service endpoints - # - # @example - # client.services.vpn.list - # client.services.vpn.create(node_id: 123) - class Vpn < Base - # List available VPN relay nodes - # GET /services/vpn - # @param params [Hash] optional filter params - # @return [OctaSpace::Response] - def list(**params) - get("/services/vpn", params:) - end - - # Create (start) a VPN session - # POST /services/vpn - # @param attrs [Hash] VPN parameters - # @return [OctaSpace::Response] - def create(**attrs) - post("/services/vpn", body: attrs) - end - end - end - end -end diff --git a/lib/octaspace/types/machines.rb b/lib/octaspace/types/machines.rb new file mode 100644 index 0000000..7391f2f --- /dev/null +++ b/lib/octaspace/types/machines.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module OctaSpace + module Types + # A GPU of a machine listing (+GpuNvidia+ in the API spec) + class Gpu < Model + # @!attribute [r] model + # @return [String] GPU model name + # @!attribute [r] mem_total_mb + # @return [Integer] total memory, MB + # @!attribute [r] core_clock + # @return [Integer, nil] core clock ceiling, MHz + # @!attribute [r] mem_clock + # @return [Integer, nil] memory clock ceiling, MHz + # @!attribute [r] power_limit + # @return [Integer, nil] power limit, W; nil if the card reports none + # @!attribute [r] tuned + # @return [Boolean] the node owner changed the card's power limit or clocks + attribute :model, :mem_total_mb, :pcie_link_gen, :pcie_link_width, + :core_clock, :mem_clock, :power_limit, :tuned + + def tuned? = tuned == true + end + + # Node reliability (an open object in the API). {#uptime} is the percent of + # time online over the last 30 days; other keys via {#[]}. + class Reliability < Model + attribute :uptime, :stability, :s_normal, :s_failed, :s_avg_d, :s_max_d + end + + # Fields shared by compute and render listings + class Machine < Model + # @!attribute [r] node_number + # @return [String] node number, e.g. "M95S-WZG8-7" + # @!attribute [r] base_price + # @return [Integer] price per hour, micro-USD + # @!attribute [r] min_rent_minutes + # @return [Integer] minimum rent, minutes (0 = none); a session stopped + # sooner is charged for that long + # @!attribute [r] memory + # @return [Integer] free memory, bytes + # @!attribute [r] disk + # @return [Integer] free disk, bytes + # @!attribute [r] net_down_mbps + # @return [Integer] download speed, Mbit/s; 0 if not measured + # @!attribute [r] net_up_mbps + # @return [Integer] upload speed, Mbit/s; 0 if not measured + attribute :node_number, :base_price, :min_rent_minutes, + :blender_score, :city, :country, :cpu_cores, :cpu_model_name, :cuda_version, + :disk, :memory, :gpu_vendor, :net_down_mbps, :net_up_mbps + attribute :gpus, model: Gpu, many: true + attribute :reliability, model: Reliability + end + + # A machine available for rent (+GET /services/compute+) + class ComputeMachine < Machine + # @!attribute [r] storage_price + # @return [Integer] price per GB of disk per hour, micro-USD + # @!attribute [r] traffic_price + # @return [Integer] price per GB of traffic, micro-USD + # @!attribute [r] disk_type + # @return [String, nil] "nvme", "ssd", "hdd" or nil if unknown + attribute :storage_price, :traffic_price, :arch, :ai_score, + :disk_type, :disk_speed_mbs, :memory_speed, :memory_type, :free_ports + end + + # A node available for render (+GET /services/render+); billed by the hour only + class RenderMachine < Machine + end + + # Outcome of one item of a batch start + class BatchResult < Model + # @!attribute [r] id + # @return [Integer, String] the client-side id of the request item + # @!attribute [r] status + # @return [Integer] 0 = started, 1 = failed + # @!attribute [r] uuid + # @return [String, nil] session uuid when started + # @!attribute [r] reason + # @return [String, nil] why the item failed + attribute :id, :status, :uuid, :reason + + def started? = status == 0 + def failed? = !started? + end + end +end diff --git a/test/fixtures/next/batch.json b/test/fixtures/next/batch.json new file mode 100644 index 0000000..8c4001e --- /dev/null +++ b/test/fixtures/next/batch.json @@ -0,0 +1,17 @@ +[ + { + "id": 1, + "status": 0, + "uuid": "eeda4f74-0ccb-420e-b718-3f0ec56e397f" + }, + { + "id": "two", + "reason": "node_number must be a node number, e.g. 7KQ4-M2XD-4", + "status": 1 + }, + { + "id": 3, + "reason": "image is required", + "status": 1 + } +] \ No newline at end of file diff --git a/test/fixtures/next/services_compute.json b/test/fixtures/next/services_compute.json new file mode 100644 index 0000000..7882463 --- /dev/null +++ b/test/fixtures/next/services_compute.json @@ -0,0 +1,167 @@ +[ + { + "memory": 63785484288, + "reliability": { + "uptime": 92.03747157535116, + "s_avg_d": 7887, + "s_failed": 0, + "s_max_d": 86930, + "s_normal": 44, + "stability": 100.0 + }, + "city": "Mukachevo", + "country": "Ukraine", + "traffic_price": 100, + "storage_price": 0, + "node_number": "7R2S-S05E-S", + "net_up_mbps": 466, + "net_down_mbps": 222, + "min_rent_minutes": 10, + "memory_type": "DDR4", + "memory_speed": 3200, + "gpus": [ + { + "core_clock": 2130, + "mem_clock": 9751, + "mem_total_mb": 24576, + "model": "NVIDIA GeForce RTX 3090", + "pcie_link_gen": 3, + "pcie_link_width": 16, + "power_limit": 390, + "tuned": false + } + ], + "gpu_vendor": "nvidia", + "free_ports": 0, + "disk_type": "nvme", + "disk_speed_mbs": 3263, + "disk": 773940592640, + "cuda_version": "13.2", + "cpu_model_name": "AMD Ryzen 5 5600X 6-Core Processor", + "cpu_cores": 12, + "blender_score": 5101, + "base_price": 100000, + "arch": "x86_64", + "ai_score": 36759 + }, + { + "memory": 128846458880, + "reliability": { + "uptime": 96.2168089037879, + "s_avg_d": 12635, + "s_failed": 0, + "s_max_d": 86960, + "s_normal": 14, + "stability": 100.0 + }, + "city": "Mukachevo", + "country": "Ukraine", + "traffic_price": 100, + "storage_price": 100, + "node_number": "M95S-WZG8-7", + "net_up_mbps": 466, + "net_down_mbps": 240, + "min_rent_minutes": 0, + "memory_type": "DDR5", + "memory_speed": 4800, + "gpus": [ + { + "core_clock": 2130, + "mem_clock": 9751, + "mem_total_mb": 24576, + "model": "NVIDIA GeForce RTX 3090", + "pcie_link_gen": 4, + "pcie_link_width": 16, + "power_limit": 390, + "tuned": false + }, + { + "core_clock": 2130, + "mem_clock": 9751, + "mem_total_mb": 24576, + "model": "NVIDIA GeForce RTX 3090", + "pcie_link_gen": 4, + "pcie_link_width": 16, + "power_limit": 390, + "tuned": false + }, + { + "core_clock": 2130, + "mem_clock": 9751, + "mem_total_mb": 24576, + "model": "NVIDIA GeForce RTX 3090", + "pcie_link_gen": 4, + "pcie_link_width": 16, + "power_limit": 390, + "tuned": false + } + ], + "gpu_vendor": "nvidia", + "free_ports": 0, + "disk_type": "nvme", + "disk_speed_mbs": 4757, + "disk": 1193761476608, + "cuda_version": "13.2", + "cpu_model_name": "AMD Ryzen Threadripper 7960X 24-Cores", + "cpu_cores": 48, + "blender_score": 15438, + "base_price": 100, + "arch": "x86_64", + "ai_score": 109451 + }, + { + "memory": 96366718976, + "reliability": { + "uptime": 83.78341416810808, + "s_avg_d": 1516, + "s_failed": 0, + "s_max_d": 2857, + "s_normal": 2, + "stability": 100.0 + }, + "city": "Mukachevo", + "country": "Ukraine", + "traffic_price": 100, + "storage_price": 100, + "node_number": "WJFM-MYWR-9", + "net_up_mbps": 466, + "net_down_mbps": 241, + "min_rent_minutes": 0, + "memory_type": "DDR5", + "memory_speed": 5600, + "gpus": [ + { + "core_clock": 2850, + "mem_clock": "null", + "mem_total_mb": 12288, + "model": "Arc B580", + "pcie_link_gen": 4, + "pcie_link_width": 8, + "power_limit": "null", + "tuned": false + }, + { + "core_clock": 2850, + "mem_clock": "null", + "mem_total_mb": 12288, + "model": "Arc B580", + "pcie_link_gen": 4, + "pcie_link_width": 4, + "power_limit": "null", + "tuned": false + } + ], + "gpu_vendor": "intel", + "free_ports": 0, + "disk_type": "nvme", + "disk_speed_mbs": 6580, + "disk": 1439272214528, + "cuda_version": null, + "cpu_model_name": "AMD Ryzen 7 7700 8-Core Processor", + "cpu_cores": 16, + "blender_score": 5062, + "base_price": 100, + "arch": "x86_64", + "ai_score": 0 + } +] \ No newline at end of file diff --git a/test/fixtures/next/services_render.json b/test/fixtures/next/services_render.json new file mode 100644 index 0000000..26ae8d4 --- /dev/null +++ b/test/fixtures/next/services_render.json @@ -0,0 +1,94 @@ +[ + { + "memory": 63785484288, + "reliability": { + "uptime": 92.03747157535116, + "s_avg_d": 7887, + "s_failed": 0, + "s_max_d": 86930, + "s_normal": 44, + "stability": 100.0 + }, + "city": "Mukachevo", + "country": "Ukraine", + "node_number": "7R2S-S05E-S", + "net_up_mbps": 466, + "net_down_mbps": 222, + "min_rent_minutes": 10, + "gpus": [ + { + "core_clock": 2130, + "mem_clock": 9751, + "mem_total_mb": 24576, + "model": "NVIDIA GeForce RTX 3090", + "pcie_link_gen": 3, + "pcie_link_width": 16, + "power_limit": 390, + "tuned": false + } + ], + "gpu_vendor": "nvidia", + "disk": 773940592640, + "cuda_version": "13.2", + "cpu_model_name": "AMD Ryzen 5 5600X 6-Core Processor", + "cpu_cores": 12, + "blender_score": 5101, + "base_price": 100000 + }, + { + "memory": 128846458880, + "reliability": { + "uptime": 96.2168089037879, + "s_avg_d": 12635, + "s_failed": 0, + "s_max_d": 86960, + "s_normal": 14, + "stability": 100.0 + }, + "city": "Mukachevo", + "country": "Ukraine", + "node_number": "M95S-WZG8-7", + "net_up_mbps": 466, + "net_down_mbps": 240, + "min_rent_minutes": 0, + "gpus": [ + { + "core_clock": 2130, + "mem_clock": 9751, + "mem_total_mb": 24576, + "model": "NVIDIA GeForce RTX 3090", + "pcie_link_gen": 4, + "pcie_link_width": 16, + "power_limit": 390, + "tuned": false + }, + { + "core_clock": 2130, + "mem_clock": 9751, + "mem_total_mb": 24576, + "model": "NVIDIA GeForce RTX 3090", + "pcie_link_gen": 4, + "pcie_link_width": 16, + "power_limit": 390, + "tuned": false + }, + { + "core_clock": 2130, + "mem_clock": 9751, + "mem_total_mb": 24576, + "model": "NVIDIA GeForce RTX 3090", + "pcie_link_gen": 4, + "pcie_link_width": 16, + "power_limit": 390, + "tuned": false + } + ], + "gpu_vendor": "nvidia", + "disk": 1193761476608, + "cuda_version": "13.2", + "cpu_model_name": "AMD Ryzen Threadripper 7960X 24-Cores", + "cpu_cores": 48, + "blender_score": 15438, + "base_price": 100 + } +] \ No newline at end of file diff --git a/test/fixtures/services/mr/index.json b/test/fixtures/services/mr/index.json deleted file mode 100644 index f129952..0000000 --- a/test/fixtures/services/mr/index.json +++ /dev/null @@ -1,21 +0,0 @@ -[ - { - "node_id": 9010, - "country": "France", - "city": "Paris", - "net_down_mbits": 872229553, - "net_up_mbits": 62488613, - "gpu": "NVIDIA GeForce RTX 5090", - "base_usd": 3400, - "traffic_usd": 1, - "reliability": { - "uptime": 100, - "rating": {"count": 0, "score": 0}, - "s_avg_d": 10117, - "s_failed": 1, - "s_max_d": 366353, - "s_normal": 381, - "stability": 99.74 - } - } -] diff --git a/test/fixtures/services/render/index.json b/test/fixtures/services/render/index.json deleted file mode 100644 index 012e2a8..0000000 --- a/test/fixtures/services/render/index.json +++ /dev/null @@ -1,13 +0,0 @@ -[ - { - "node_id": 9042, - "country": "Germany", - "city": "Frankfurt", - "gpu": "NVIDIA RTX A6000", - "net_down_mbits": 125000000, - "net_up_mbits": 62500000, - "base_usd": 1200, - "traffic_usd": 1, - "reliability": 99.5 - } -] diff --git a/test/fixtures/services/vpn/index.json b/test/fixtures/services/vpn/index.json deleted file mode 100644 index d946171..0000000 --- a/test/fixtures/services/vpn/index.json +++ /dev/null @@ -1,12 +0,0 @@ -[ - { - "node_id": 9539, - "country": "Poland", - "city": "Zakrzew", - "residential": false, - "net_down_mbits": 0, - "net_up_mbits": 0, - "traffic_price_usd": 0.005366743801677206, - "traffic_price_ether": 0.05 - } -] diff --git a/test/octaspace/json_null_test.rb b/test/octaspace/json_null_test.rb new file mode 100644 index 0000000..fbabeb5 --- /dev/null +++ b/test/octaspace/json_null_test.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "test_helper" + +class OctaSpace::JsonNullTest < Minitest::Test + def test_null_strings_replaced_recursively_but_not_keys + data = {"null" => "null", "a" => ["null", {"b" => "null", "c" => "x"}], "d" => 1} + OctaSpace::JsonNull.null_strings!(data) + + assert_equal({"null" => nil, "a" => [nil, {"b" => nil, "c" => "x"}], "d" => 1}, data) + end + + def test_number + assert_equal 390, OctaSpace::JsonNull.number(" 390 ") + assert_in_delta 1.5, OctaSpace::JsonNull.number("1.5") + assert_nil OctaSpace::JsonNull.number("") + assert_nil OctaSpace::JsonNull.number("null") + assert_equal 7, OctaSpace::JsonNull.number(7) + assert_nil OctaSpace::JsonNull.number(nil) + end +end diff --git a/test/octaspace/model_test.rb b/test/octaspace/model_test.rb new file mode 100644 index 0000000..f99b458 --- /dev/null +++ b/test/octaspace/model_test.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "test_helper" + +class OctaSpace::ModelTest < Minitest::Test + class Inner < OctaSpace::Model + attribute :name + end + + class Outer < OctaSpace::Model + attribute :id + attribute :inner, model: Inner + attribute :items, model: Inner, many: true + end + + def setup + super + @outer = Outer.new("id" => 1, "inner" => {"name" => "a"}, "items" => [{"name" => "b"}, nil], "extra" => true) + end + + def test_readers_and_nested_models + assert_equal 1, @outer.id + assert_equal "a", @outer.inner.name + assert_equal ["b", nil], @outer.items.map { |i| i&.name } + assert_same @outer.inner, @outer.inner + end + + def test_missing_and_null_values + empty = Outer.new({}) + assert_nil empty.id + assert_nil empty.inner + assert_equal [], Outer.new("items" => nil).items + refute empty.key?(:id) + end + + def test_unknown_fields_and_raw_hash + assert_equal true, @outer[:extra] + assert_equal true, @outer["extra"] + assert_equal({"name" => "a"}, @outer.to_h["inner"]) + end + + def test_equality_and_inspect + assert_equal Outer.new("id" => 1), Outer.new(id: 1) + refute_equal Outer.new("id" => 1), Inner.new("id" => 1) + assert_equal "#", Outer.new("id" => 1, "inner" => {}).inspect + end + + def test_pattern_matching + case @outer + in {id: Integer => id, extra: true} + assert_equal 1, id + end + end + + def test_attributes_are_inherited + sub = Class.new(Outer) { attribute :more } + assert_equal %i[id inner items more], sub.attribute_names + assert_equal %i[id inner items], Outer.attribute_names + end +end diff --git a/test/octaspace/payload_helpers_test.rb b/test/octaspace/payload_helpers_test.rb index 9699653..58593ac 100644 --- a/test/octaspace/payload_helpers_test.rb +++ b/test/octaspace/payload_helpers_test.rb @@ -18,16 +18,4 @@ def test_parse_port_list_returns_empty_array_for_invalid_string def test_parse_port_list_returns_empty_array_for_nil assert_equal [], OctaSpace::PayloadHelpers.parse_port_list(nil) end - - def test_normalize_marketplace_bandwidth_preserves_small_values - assert_equal 250.0, OctaSpace::PayloadHelpers.normalize_marketplace_bandwidth(250) - end - - def test_normalize_marketplace_bandwidth_converts_large_values - assert_in_delta 6977.836424, OctaSpace::PayloadHelpers.normalize_marketplace_bandwidth(872_229_553), 0.000001 - end - - def test_normalize_marketplace_bandwidth_returns_nil_for_nil - assert_nil OctaSpace::PayloadHelpers.normalize_marketplace_bandwidth(nil) - end end diff --git a/test/octaspace/resources/compute_test.rb b/test/octaspace/resources/compute_test.rb new file mode 100644 index 0000000..f45a3a3 --- /dev/null +++ b/test/octaspace/resources/compute_test.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +require "test_helper" + +class OctaSpace::ComputeResourceTest < Minitest::Test + BASE = StubHelpers::BASE_URL + UUID = "2a63b54f-f243-45ab-8416-2728e178a384" + + def setup + super + @client = test_client + end + + # --- list --- + + def test_list_returns_machines_from_live_fixture + stub_get("/services/compute", fixture_path: "next/services_compute.json") + machines = @client.compute.list + + assert_equal 3, machines.size + m = machines.find { |x| x.node_number == "M95S-WZG8-7" } + assert_instance_of OctaSpace::Types::ComputeMachine, m + assert_equal 100, m.base_price + assert_equal 100, m.storage_price + assert_equal 0, m.min_rent_minutes + assert_equal 240, m.net_down_mbps + assert_equal "nvme", m.disk_type + assert_equal "NVIDIA GeForce RTX 3090", m.gpus.first.model + assert_equal 24_576, m.gpus.first.mem_total_mb + assert_in_delta 96.2, m.reliability.uptime, 0.1 + assert_equal 14, m.reliability[:s_normal] + end + + def test_list_turns_null_strings_into_nil + stub_get("/services/compute", fixture_path: "next/services_compute.json") + arc = @client.compute.list.find { |x| x.gpus.any? { |g| g.model.to_s.include?("Arc") } } + + assert_nil arc.gpus.first.mem_clock + assert_nil arc.gpus.first.power_limit + end + + def test_list_coerces_string_gpu_integers + stub_request(:get, "#{BASE}/services/compute").to_return(status: 200, + body: '[{"node_number":"A","gpus":[{"power_limit":"390","mem_clock":"","core_clock":"n/a"}]}]') + gpu = @client.compute.list.first.gpus.first + + assert_equal 390, gpu.power_limit + assert_nil gpu.mem_clock + assert_nil gpu.core_clock + end + + def test_list_works_without_api_key + stub_request(:get, "#{BASE}/services/compute").to_return(status: 200, body: "[]") + assert_equal [], OctaSpace::Client.new.compute.list + assert_requested(:get, "#{BASE}/services/compute") { |req| !req.headers.key?("Authorization") } + end + + def test_list_sends_key_when_present + stub_request(:get, "#{BASE}/services/compute").to_return(status: 200, body: "[]") + @client.compute.list + assert_requested :get, "#{BASE}/services/compute", headers: {"Authorization" => "Bearer test_key_123"} + end + + def test_unknown_fields_are_kept + stub_request(:get, "#{BASE}/services/compute").to_return(status: 200, body: '[{"node_number":"A","future":42}]') + m = @client.compute.list.first + + assert_equal 42, m[:future] + assert_equal({"node_number" => "A", "future" => 42}, m.to_h) + end + + # --- start --- + + def test_start_sends_object_and_returns_uuid + stub_request(:post, "#{BASE}/services/compute") + .with(body: {node_number: "M95S-WZG8-7", image: "alpine:3.20", disk_size: 1, envs: {"A" => "1"}, + http_ports: [8080], ssh: false}.to_json) + .to_return(status: 201, body: %({"uuid":"#{UUID}"})) + + uuid = @client.compute.start(node_number: "M95S-WZG8-7", image: "alpine:3.20", disk_size: 1, + envs: {"A" => "1"}, http_ports: [8080], ssh: false, fm: nil) + assert_equal UUID, uuid + end + + def test_start_requires_node_number_and_image + error = assert_raises(ArgumentError) { @client.compute.start(image: "x") } + assert_includes error.message, "node_number" + end + + def test_start_rejects_unknown_parameters + error = assert_raises(ArgumentError) { @client.compute.start(node_number: "A", image: "x", start_command: "sh") } + assert_includes error.message, "start_command" + end + + def test_start_maps_400_to_validation_error + stub_request(:post, "#{BASE}/services/compute").to_return(status: 400, body: '{"message":"Insufficient balance"}') + + error = assert_raises(OctaSpace::ValidationError) { @client.compute.start(node_number: "A", image: "x") } + assert_equal "Insufficient balance", error.message + end + + def test_start_is_not_retried + stub_request(:post, "#{BASE}/services/compute").to_return(status: 503, body: "{}") + client = test_client(max_retries: 3) + + assert_raises(OctaSpace::ServiceUnavailableError) { client.compute.start(node_number: "A", image: "x") } + assert_requested :post, "#{BASE}/services/compute", times: 1 + end + + # --- start_batch --- + + def test_start_batch_assigns_positions_and_parses_results + stub_request(:post, "#{BASE}/services/compute") + .with(body: [{id: "one", node_number: "A", image: "x"}, {id: 1, node_number: "B", image: "x"}].to_json) + .to_return(status: 200, body: fixture("next/batch.json")) + + results = @client.compute.start_batch([{id: "one", node_number: "A", image: "x"}, {node_number: "B", image: "x"}]) + assert_equal 3, results.size + assert results[0].started? + assert_equal "eeda4f74-0ccb-420e-b718-3f0ec56e397f", results[0].uuid + assert results[1].failed? + assert_equal "two", results[1].id + assert_match(/node_number must be/, results[1].reason) + end + + def test_start_batch_rejects_duplicate_ids + error = assert_raises(ArgumentError) do + @client.compute.start_batch([{id: 1, node_number: "A", image: "x"}, {node_number: "B", image: "x"}, {node_number: "C", image: "x"}]) + end + assert_includes error.message, "duplicate batch id 1" + end + + def test_start_batch_rejects_empty_and_unknown + assert_raises(ArgumentError) { @client.compute.start_batch([]) } + assert_raises(ArgumentError) { @client.compute.start_batch([{node_number: "A", image: "x", node_id: 1}]) } + end + + # --- restart --- + + def test_restart_without_params_sends_no_body + stub_request(:post, "#{BASE}/services/compute/#{UUID}/restart") + .with { |req| req.body.to_s.empty? } + .to_return(status: 202, body: %({"uuid":"#{UUID}"})) + + assert_equal UUID, @client.compute.restart(UUID) + end + + def test_restart_with_params_sends_them + stub_request(:post, "#{BASE}/services/compute/#{UUID}/restart") + .with(body: {disk_size: 10, envs: {"K" => "V"}, entrypoint: "sh -c", args: "sleep 1"}.to_json) + .to_return(status: 202, body: "") + + assert_equal UUID, @client.compute.restart(UUID, disk_size: 10, envs: {"K" => "V"}, entrypoint: "sh -c", args: "sleep 1") + end + + def test_restart_rejects_image + assert_raises(ArgumentError) { @client.compute.restart(UUID, image: "other") } + end + + def test_restart_conflict_and_cooldown + stub_request(:post, "#{BASE}/services/compute/#{UUID}/restart").to_return( + {status: 409, body: '{"message":"Session is still starting or restarting"}'}, + {status: 429, body: '{"message":"Session was restarted too recently, retry in 288 seconds"}', + headers: {"Retry-After" => "288"}} + ) + + assert_raises(OctaSpace::ConflictError) { @client.compute.restart(UUID) } + error = assert_raises(OctaSpace::RateLimitError) { @client.compute.restart(UUID) } + assert_equal 288, error.retry_after + end +end diff --git a/test/octaspace/resources/render_test.rb b/test/octaspace/resources/render_test.rb new file mode 100644 index 0000000..73f0fb6 --- /dev/null +++ b/test/octaspace/resources/render_test.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "test_helper" + +class OctaSpace::RenderResourceTest < Minitest::Test + BASE = StubHelpers::BASE_URL + + def setup + super + @client = test_client + end + + def test_list_returns_render_machines + stub_get("/services/render", fixture_path: "next/services_render.json") + machines = @client.render.list + + assert(machines.all?(OctaSpace::Types::RenderMachine)) + m = machines.first + assert_equal "7R2S-S05E-S", m.node_number + assert_equal 100_000, m.base_price + assert_equal 5101, m.blender_score + refute_respond_to m, :storage_price + end + + def test_start_sends_only_node_number + stub_request(:post, "#{BASE}/services/render") + .with(body: '{"node_number":"7R2S-S05E-S"}') + .to_return(status: 201, body: '{"uuid":"r-1"}') + + assert_equal "r-1", @client.render.start(node_number: "7R2S-S05E-S") + end + + def test_start_503_is_service_unavailable + stub_request(:post, "#{BASE}/services/render").to_return(status: 503, body: '{"message":"Service is temporarily unavailable"}') + + error = assert_raises(OctaSpace::ServiceUnavailableError) { @client.render.start(node_number: "A") } + assert_equal "Service is temporarily unavailable", error.message + end + + def test_start_batch_reports_unavailable_per_item + stub_request(:post, "#{BASE}/services/render") + .with(body: '[{"id":0,"node_number":"7R2S-S05E-S"},{"id":1,"node_number":"ZZZZ-ZZZZ-Z"}]') + .to_return(status: 200, body: '[{"id":0,"status":1,"reason":"Service is temporarily unavailable"},' \ + '{"id":1,"status":1,"reason":"Invalid node number"}]') + + results = @client.render.start_batch([{node_number: "7R2S-S05E-S"}, {node_number: "ZZZZ-ZZZZ-Z"}]) + assert(results.all?(&:failed?)) + assert_equal "Service is temporarily unavailable", results.first.reason + end + + def test_start_batch_rejects_legacy_fields + assert_raises(ArgumentError) { @client.render.start_batch([{node_number: "A", disk_size: 100}]) } + end +end diff --git a/test/octaspace/resources/services_test.rb b/test/octaspace/resources/services_test.rb index 5ebfbe2..0fa6b6f 100644 --- a/test/octaspace/resources/services_test.rb +++ b/test/octaspace/resources/services_test.rb @@ -3,134 +3,11 @@ require "test_helper" class OctaSpace::ServicesResourceTest < Minitest::Test - # Request payloads from the removed playground presets. - PAYLOADS = { - "services.mr.create" => {node_id: 1, disk_size: 10, image: "ubuntu:24.04", app: "249b4cb3-3db1-4c06-98a4-772ba88cd81c"}, - "services.render.create" => {node_id: 1, disk_size: 100}, - "services.vpn.create" => {node_id: 1, subkind: "wg"}, - "services.session.stop" => {uuid: "sess-abc-123", score: 5} - }.freeze - def setup super @client = test_client end - # --- Services namespace --- - - def test_services_exposes_mr_vpn_render - assert_instance_of OctaSpace::Resources::Services::MachineRental, @client.services.mr - assert_instance_of OctaSpace::Resources::Services::Vpn, @client.services.vpn - assert_instance_of OctaSpace::Resources::Services::Render, @client.services.render - end - - def test_services_session_returns_proxy - proxy = @client.services.session("sess-abc-123") - assert_instance_of OctaSpace::Resources::Services::SessionProxy, proxy - end - - # --- MachineRental --- - - def test_mr_list_returns_array - stub_get("/services/mr", fixture_path: "services/mr/index.json") - response = @client.services.mr.list - assert response.success? - assert_kind_of Array, response.data - assert_equal 9010, response.data.first["node_id"] - assert_equal "France", response.data.first["country"] - assert_kind_of Hash, response.data.first["reliability"] - end - - def test_mr_create_returns_success - payload = PAYLOADS.fetch("services.mr.create").merge( - organization_id: 77, - project_id: 88 - ) - stub_request(:post, "#{StubHelpers::BASE_URL}/services/mr") - .with(body: [ - { - id: 0, - node_id: payload[:node_id], - disk_size: payload[:disk_size], - image: payload[:image], - app: payload[:app], - envs: {}, - ports: [], - http_ports: [], - start_command: "", - entrypoint: "", - organization_id: 77, - project_id: 88 - } - ].to_json) - .to_return(status: 201, body: '{"uuid":"new-sess","status":"starting"}', headers: json_headers) - - response = @client.services.mr.create(**payload) - assert response.success? - assert_equal "new-sess", response.data["uuid"] - end - - def test_mr_create_raises_api_error_on_400_object_rejection - payload = PAYLOADS.fetch("services.mr.create") - stub_request(:post, "#{StubHelpers::BASE_URL}/services/mr") - .to_return(status: 400, body: '{"message":"Node not found"}', headers: json_headers) - - assert_raises(OctaSpace::ApiError) { @client.services.mr.create(**payload) } - end - - def test_mr_create_raises_provision_rejected_error_on_batch_rejection - payload = PAYLOADS.fetch("services.mr.create") - stub_request(:post, "#{StubHelpers::BASE_URL}/services/mr") - .to_return( - status: 200, - body: '[{"id":0,"reason":"Node not found","status":1}]', - headers: json_headers - ) - - error = assert_raises(OctaSpace::ProvisionRejectedError) { @client.services.mr.create(**payload) } - assert_includes error.message, "Node not found" - assert_equal 1, error.rejections.length - end - - def test_mr_list_raises_not_found_on_404 - stub_error(:get, "/services/mr", status: 404, message: "Not Found") - assert_raises(OctaSpace::NotFoundError) { @client.services.mr.list } - end - - # --- VPN --- - - def test_vpn_list_returns_array - stub_get("/services/vpn", fixture_path: "services/vpn/index.json") - response = @client.services.vpn.list - assert response.success? - assert_kind_of Array, response.data - assert_equal 9539, response.data.first["node_id"] - refute response.data.first["residential"] - end - - def test_vpn_create_returns_success - payload = PAYLOADS.fetch("services.vpn.create") - stub_post("/services/vpn", status: 201, body: '{"uuid":"new-vpn","status":"starting"}') - response = @client.services.vpn.create(**payload) - assert response.success? - end - - # --- Render --- - - def test_render_list_returns_success - stub_get("/services/render", status: 200, fixture_path: "services/render/index.json") - response = @client.services.render.list - assert response.success? - assert_equal "NVIDIA RTX A6000", response.data.first["gpu"] - end - - def test_render_create_returns_success - payload = PAYLOADS.fetch("services.render.create") - stub_post("/services/render", status: 201, body: '{"uuid":"new-render","status":"queued"}') - response = @client.services.render.create(**payload) - assert response.success? - end - # --- SessionProxy --- def test_session_proxy_info @@ -160,9 +37,8 @@ def test_session_proxy_logs_with_recent_flag end def test_session_proxy_stop - payload = PAYLOADS.fetch("services.session.stop") stub_post("/services/sess-abc-123/stop", status: 200, body: '{"status":"stopped"}') - response = @client.services.session(payload[:uuid]).stop(score: payload[:score]) + response = @client.services.session("sess-abc-123").stop(score: 5) assert response.success? end From 5f450a2c9212f34e6723cace8f14a967fb0ba31b Mon Sep 17 00:00:00 2001 From: secretpray Date: Tue, 3 Nov 2026 13:10:34 +0200 Subject: [PATCH 5/9] feat!: sessions on the next API - client.sessions: list (GET /sessions), get (GET /services/{uuid}/info), logs(uuid, tail:) (GET /services/{uuid}/logs), stop (POST .../stop -> 202, retried as idempotent), wait_ready (polls until "ready", yields each state, SessionStoppedError on 404, WaitTimeoutError), and wait_stopped (polls until 404) - typed sessions picked by the service discriminator: ComputeSession, RenderSession, and a plain Session for an unknown service; NodeHw, SessionSsh/SshEndpoint (endpoint prefers direct over proxy), SessionLogs/LogEntry; started_time / time convert Unix milliseconds - Model.redact: inspect hides ssh.web and fm, URLs that grant access - removed: services.session(uuid) proxy (info/logs/stop), logs(recent:), stop(score:), sessions.list(**params) and the old Types::Session --- lib/octaspace.rb | 3 +- lib/octaspace/client.rb | 3 +- lib/octaspace/errors.rb | 8 + lib/octaspace/model.rb | 16 +- lib/octaspace/resources/services.rb | 17 -- .../resources/services/session_proxy.rb | 54 ------ lib/octaspace/resources/sessions.rb | 134 ++++++++++++- lib/octaspace/types/session.rb | 145 +++++++++++++- .../next/session_compute_net_mbps.json | 57 ++++++ test/fixtures/next/session_compute_ready.json | 61 ++++++ .../next/session_compute_ssh_proxy.json | 61 ++++++ .../next/session_compute_starting.json | 35 ++++ test/fixtures/next/session_logs.json | 26 +++ test/fixtures/services/session/info.json | 9 - test/fixtures/services/session/logs.json | 13 -- test/fixtures/sessions/index.json | 10 - test/fixtures/sessions/recent.json | 13 -- test/octaspace/client_test.rb | 3 +- test/octaspace/resources/services_test.rb | 55 ------ test/octaspace/resources/sessions_test.rb | 178 ++++++++++++++++-- test/octaspace/types_test.rb | 15 -- 21 files changed, 693 insertions(+), 223 deletions(-) delete mode 100644 lib/octaspace/resources/services.rb delete mode 100644 lib/octaspace/resources/services/session_proxy.rb create mode 100644 test/fixtures/next/session_compute_net_mbps.json create mode 100644 test/fixtures/next/session_compute_ready.json create mode 100644 test/fixtures/next/session_compute_ssh_proxy.json create mode 100644 test/fixtures/next/session_compute_starting.json create mode 100644 test/fixtures/next/session_logs.json delete mode 100644 test/fixtures/services/session/info.json delete mode 100644 test/fixtures/services/session/logs.json delete mode 100644 test/fixtures/sessions/index.json delete mode 100644 test/fixtures/sessions/recent.json delete mode 100644 test/octaspace/resources/services_test.rb 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 From cfb8d4f27acb2a22e30a90d2204316084fd65eb8 Mon Sep 17 00:00:00 2001 From: secretpray Date: Tue, 3 Nov 2026 13:13:50 +0200 Subject: [PATCH 6/9] feat!: hosting, stats, balance and network on the next API - client.hosting: nodes (GET /hosting/nodes, online nodes only) as HostingNode with prices, services (offered?/reason), activity, system (memory/disk usage, GPUs), location, online_time; set_prices(number, base:, storage:, traffic:, min_rent_minutes:) (PATCH -> 204), only given fields, integers only - client.stats: gpu_current(verified:), gpu_history(gpu_name:, verified:, from:, to:, step:) keyed by model with GpuHistory#points turning the parallel arrays into points, gpu_locations; no key sent - client.accounts.balance -> Balance {balance, currency, decimals} with to_usd / formatted; client.network.info -> typed Network - OctaSpace::Money: format, to_usd (Rational), from_usd; exact integer micro-USD arithmetic - "null" strings repaired in stats and hosting GPU data - removed: nodes (list/find/ident/logs/reboot/update_prices), apps, idle_jobs, accounts.profile / generate_wallet, PayloadHelpers and the old Types::Account/Balance/Node value objects --- lib/octaspace.rb | 18 +- lib/octaspace/client.rb | 25 +- lib/octaspace/money.rb | 69 ++++++ lib/octaspace/payload_helpers.rb | 33 --- lib/octaspace/resources/accounts.rb | 29 +-- lib/octaspace/resources/apps.rb | 19 -- lib/octaspace/resources/hosting.rb | 58 +++++ lib/octaspace/resources/idle_jobs.rb | 32 --- lib/octaspace/resources/network.rb | 12 +- lib/octaspace/resources/nodes.rb | 63 ----- lib/octaspace/resources/stats.rb | 54 +++++ lib/octaspace/types/account.rb | 21 +- lib/octaspace/types/balance.rb | 11 - lib/octaspace/types/hosting.rb | 107 +++++++++ lib/octaspace/types/network.rb | 57 +++++ lib/octaspace/types/node.rb | 12 - lib/octaspace/types/stats.rb | 91 +++++++ test/fixtures/accounts/balance.json | 4 - test/fixtures/accounts/show.json | 6 - test/fixtures/apps/index.json | 26 -- test/fixtures/idle_jobs/index.json | 18 -- test/fixtures/idle_jobs/logs.json | 7 - test/fixtures/idle_jobs/show.json | 8 - test/fixtures/network/index.json | 26 -- test/fixtures/network/power.json | 5 - test/fixtures/network/stats.json | 7 - test/fixtures/next/account_balance.json | 5 + test/fixtures/next/hosting_nodes.json | 248 ++++++++++++++++++++ test/fixtures/next/network.json | 34 +++ test/fixtures/next/stats_gpu_current.json | 55 +++++ test/fixtures/next/stats_gpu_history.json | 160 +++++++++++++ test/fixtures/next/stats_gpu_locations.json | 22 ++ test/fixtures/nodes/index.json | 18 -- test/fixtures/nodes/show.json | 8 - test/octaspace/client_test.rb | 12 +- test/octaspace/money_test.rb | 36 +++ test/octaspace/payload_helpers_test.rb | 21 -- test/octaspace/resources/accounts_test.rb | 54 ++--- test/octaspace/resources/apps_test.rb | 34 --- test/octaspace/resources/hosting_test.rb | 64 +++++ test/octaspace/resources/idle_jobs_test.rb | 34 --- test/octaspace/resources/network_test.rb | 33 ++- test/octaspace/resources/nodes_test.rb | 53 ----- test/octaspace/resources/stats_test.rb | 67 ++++++ test/octaspace/types_test.rb | 58 ----- 45 files changed, 1229 insertions(+), 605 deletions(-) create mode 100644 lib/octaspace/money.rb delete mode 100644 lib/octaspace/payload_helpers.rb delete mode 100644 lib/octaspace/resources/apps.rb create mode 100644 lib/octaspace/resources/hosting.rb delete mode 100644 lib/octaspace/resources/idle_jobs.rb delete mode 100644 lib/octaspace/resources/nodes.rb create mode 100644 lib/octaspace/resources/stats.rb delete mode 100644 lib/octaspace/types/balance.rb create mode 100644 lib/octaspace/types/hosting.rb create mode 100644 lib/octaspace/types/network.rb delete mode 100644 lib/octaspace/types/node.rb create mode 100644 lib/octaspace/types/stats.rb delete mode 100644 test/fixtures/accounts/balance.json delete mode 100644 test/fixtures/accounts/show.json delete mode 100644 test/fixtures/apps/index.json delete mode 100644 test/fixtures/idle_jobs/index.json delete mode 100644 test/fixtures/idle_jobs/logs.json delete mode 100644 test/fixtures/idle_jobs/show.json delete mode 100644 test/fixtures/network/index.json delete mode 100644 test/fixtures/network/power.json delete mode 100644 test/fixtures/network/stats.json create mode 100644 test/fixtures/next/account_balance.json create mode 100644 test/fixtures/next/hosting_nodes.json create mode 100644 test/fixtures/next/network.json create mode 100644 test/fixtures/next/stats_gpu_current.json create mode 100644 test/fixtures/next/stats_gpu_history.json create mode 100644 test/fixtures/next/stats_gpu_locations.json delete mode 100644 test/fixtures/nodes/index.json delete mode 100644 test/fixtures/nodes/show.json create mode 100644 test/octaspace/money_test.rb delete mode 100644 test/octaspace/payload_helpers_test.rb delete mode 100644 test/octaspace/resources/apps_test.rb create mode 100644 test/octaspace/resources/hosting_test.rb delete mode 100644 test/octaspace/resources/idle_jobs_test.rb delete mode 100644 test/octaspace/resources/nodes_test.rb create mode 100644 test/octaspace/resources/stats_test.rb delete mode 100644 test/octaspace/types_test.rb diff --git a/lib/octaspace.rb b/lib/octaspace.rb index de16fd4..490d771 100644 --- a/lib/octaspace.rb +++ b/lib/octaspace.rb @@ -1,29 +1,29 @@ # frozen_string_literal: true require "octaspace/version" +require "octaspace/configuration" require "octaspace/errors" -require "octaspace/payload_helpers" require "octaspace/response" require "octaspace/json_null" +require "octaspace/money" require "octaspace/model" -require "octaspace/configuration" require "octaspace/middleware/url_rotator" require "octaspace/transport/base" require "octaspace/transport/faraday_transport" -require "octaspace/types/account" -require "octaspace/types/balance" -require "octaspace/types/node" require "octaspace/types/machines" require "octaspace/types/session" +require "octaspace/types/account" +require "octaspace/types/network" +require "octaspace/types/hosting" +require "octaspace/types/stats" 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/stats" require "octaspace/resources/compute" require "octaspace/resources/render" -require "octaspace/resources/idle_jobs" +require "octaspace/resources/sessions" +require "octaspace/resources/hosting" require "octaspace/client" # OctaSpace Ruby SDK diff --git a/lib/octaspace/client.rb b/lib/octaspace/client.rb index 64f40e0..07bd046 100644 --- a/lib/octaspace/client.rb +++ b/lib/octaspace/client.rb @@ -35,8 +35,20 @@ module OctaSpace # on_response: ->(resp) { puts "← #{resp.status}" } # ) class Client - attr_reader :accounts, :nodes, :sessions, :apps, - :network, :idle_jobs, :compute, :render + # @return [Resources::Accounts] balance + attr_reader :accounts + # @return [Resources::Compute] machines for rent: list, start, start_batch, restart + attr_reader :compute + # @return [Resources::Hosting] nodes of the account and their prices + attr_reader :hosting + # @return [Resources::Network] public platform statistics + 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 + attr_reader :stats # @param api_key [String, nil] API key for authentication (optional for public endpoints) # @param opts [Hash] Per-instance configuration overrides. @@ -45,13 +57,12 @@ def initialize(api_key: nil, transport: nil, **opts) @config = build_config(api_key, opts) @transport = transport || build_transport @accounts = Resources::Accounts.new(@transport) - @nodes = Resources::Nodes.new(@transport) - @sessions = Resources::Sessions.new(@transport) - @apps = Resources::Apps.new(@transport) - @network = Resources::Network.new(@transport) - @idle_jobs = Resources::IdleJobs.new(@transport) @compute = Resources::Compute.new(@transport) + @hosting = Resources::Hosting.new(@transport) + @network = Resources::Network.new(@transport) @render = Resources::Render.new(@transport) + @sessions = Resources::Sessions.new(@transport) + @stats = Resources::Stats.new(@transport) end # Shut down persistent connections (only relevant in keep_alive mode) diff --git a/lib/octaspace/money.rb b/lib/octaspace/money.rb new file mode 100644 index 0000000..e0f551b --- /dev/null +++ b/lib/octaspace/money.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module OctaSpace + # Conversions for micro-USD, the integer unit of every price, charge and + # balance in the API (1 micro-USD = $0.000001). + # + # @example + # OctaSpace::Money.format(100) # => "$0.000100" + # OctaSpace::Money.to_usd(1_250_000) # => (5/4) + # OctaSpace::Money.from_usd("0.25") # => 250000 + module Money + # Number of micro-USD in one dollar + MICRO_USD_PER_USD = 1_000_000 + + module_function + + # Converts an integer amount to dollars, exactly. + # @param amount [Integer] micro-USD (or units of 10^-decimals) + # @param decimals [Integer] decimal places of the unit (6 for micro-USD) + # @return [Rational] + def to_usd(amount, decimals: 6) + Rational(integer!(amount), 10**decimals) + end + + # Converts dollars to micro-USD, rounded to the nearest integer + # (e.g. for +hosting.set_prices+). + # @param usd [Integer, Rational, String, Float] a String like "0.25" is + # exact; a Float goes through its shortest decimal form + # @return [Integer] + def from_usd(usd) + value = case usd + when Integer, Rational then usd + when Float + raise ArgumentError, "not a finite dollar amount: #{usd}" unless usd.finite? + + Rational(usd.to_s) + when String + text = usd.strip.delete_prefix("$") + raise ArgumentError, "not a dollar amount: #{usd.inspect}" unless text.match?(/\A-?\d+(\.\d+)?\z/) + + Rational(text) + else + raise ArgumentError, "not a dollar amount: #{usd.inspect}" + end + (value * MICRO_USD_PER_USD).round + end + + # Formats an integer amount as dollars with all decimal places, using + # integer arithmetic: 100 → "$0.000100", -1_250_000 → "-$1.250000". + # @param amount [Integer] micro-USD (or units of 10^-decimals) + # @param decimals [Integer] + # @return [String] + def format(amount, decimals: 6) + amount = integer!(amount) + whole, fraction = amount.abs.divmod(10**decimals) + sign = amount.negative? ? "-" : "" + return "#{sign}$#{whole}" if decimals.zero? + + "#{sign}$#{whole}.#{fraction.to_s.rjust(decimals, "0")}" + end + + def integer!(amount) + raise ArgumentError, "not an integer amount: #{amount.inspect}" unless amount.is_a?(Integer) + + amount + end + private_class_method :integer! + end +end diff --git a/lib/octaspace/payload_helpers.rb b/lib/octaspace/payload_helpers.rb deleted file mode 100644 index b944b88..0000000 --- a/lib/octaspace/payload_helpers.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -require "json" - -module OctaSpace - module PayloadHelpers - module_function - - def parse_port_list(value) - case value - when nil - [] - when Array - value - when String - parse_stringified_port_list(value) - else - Array(value) - end - end - - def parse_stringified_port_list(value) - stripped = value.strip - return [] if stripped.empty? - - parsed = JSON.parse(stripped) - parsed.is_a?(Array) ? parsed : [] - rescue JSON::ParserError - [] - end - private_class_method :parse_stringified_port_list - end -end diff --git a/lib/octaspace/resources/accounts.rb b/lib/octaspace/resources/accounts.rb index 52b5579..3b2b39a 100644 --- a/lib/octaspace/resources/accounts.rb +++ b/lib/octaspace/resources/accounts.rb @@ -2,31 +2,20 @@ module OctaSpace module Resources - # Account-related API endpoints + # The account the API key belongs to # # @example - # client.accounts.profile - # client.accounts.balance + # client.accounts.balance.formatted # => "$2.147661" class Accounts < Base - # Fetch the authenticated user's profile - # GET /accounts - # @return [OctaSpace::Response] - def profile - get("/accounts") - end - - # Fetch the authenticated user's balance + # Account balance. # GET /accounts/balance - # @return [OctaSpace::Response] + # + # 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. + # @return [OctaSpace::Types::Balance] def balance - get("/accounts/balance") - end - - # Generate / create a new wallet for the authenticated user - # POST /accounts - # @return [OctaSpace::Response] - def generate_wallet - post("/accounts") + Types::Balance.new(get("/accounts/balance").data) end end end diff --git a/lib/octaspace/resources/apps.rb b/lib/octaspace/resources/apps.rb deleted file mode 100644 index 3729172..0000000 --- a/lib/octaspace/resources/apps.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module OctaSpace - module Resources - # Apps API endpoint - # - # @example - # client.apps.list - class Apps < Base - # List available apps - # GET /apps - # @param params [Hash] optional filter params - # @return [OctaSpace::Response] - def list(**params) - get("/apps", params:) - end - end - end -end diff --git a/lib/octaspace/resources/hosting.rb b/lib/octaspace/resources/hosting.rb new file mode 100644 index 0000000..d8cbcf9 --- /dev/null +++ b/lib/octaspace/resources/hosting.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module OctaSpace + module Resources + # Nodes the account hosts + # + # @example + # client.hosting.nodes.each { |node| puts "#{node.number} #{node.status}" } + # client.hosting.set_prices("M95S-WZG8-7", base: OctaSpace::Money.from_usd("0.25")) + class Hosting < Base + PRICE_FIELDS = %i[base storage traffic min_rent_minutes].freeze + + # GPU fields nodes may send as strings in +system.gpus+ + 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. + # @return [Array] + def nodes + data = JsonNull.null_strings!(Array(get("/hosting/nodes").data)) + JsonNull.gpu_numbers!(data.map { |node| node.is_a?(Hash) ? node["system"] : nil }.compact, HOSTING_GPU_FIELDS) + Types::HostingNode.wrap_all(data) + end + + # Updates some prices of a node. + # PATCH /hosting/nodes/{number}/prices (→ 204) + # + # Only the given fields are sent; at least one is required. Amounts are + # integer micro-USD (convert dollars with {OctaSpace::Money.from_usd}). + # Ranges: base and traffic 100..1_000_000_000, storage 0..1_000_000_000, + # min_rent_minutes 0..60. The API refuses other values with + # {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. + # + # @param number [String] node number; case and dashes are ignored + # @param base [Integer, nil] + # @param storage [Integer, nil] + # @param traffic [Integer, nil] + # @param min_rent_minutes [Integer, nil] + # @return [nil] + def set_prices(number, **prices) + body = compact_params(prices, PRICE_FIELDS) + raise ArgumentError, "set_prices needs at least one of #{PRICE_FIELDS.join(", ")}" if body.empty? + + body.each do |field, value| + raise ArgumentError, "#{field} must be an Integer (micro-USD or minutes), got #{value.inspect}" unless value.is_a?(Integer) + end + patch("/hosting/nodes/#{encode(number)}/prices", body: body) + nil + end + end + end +end diff --git a/lib/octaspace/resources/idle_jobs.rb b/lib/octaspace/resources/idle_jobs.rb deleted file mode 100644 index f19e1cb..0000000 --- a/lib/octaspace/resources/idle_jobs.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -module OctaSpace - module Resources - # Idle Jobs API endpoints - # - # Each idle job is identified by both a node ID and a job ID. - # - # @example - # client.idle_jobs.find(node_id: 69, job_id: 42) - # client.idle_jobs.logs(node_id: 69, job_id: 42) - class IdleJobs < Base - # Fetch a single idle job status - # GET /idle_jobs/:node_id/:job_id - # @param node_id [Integer, String] - # @param job_id [Integer, String] - # @return [OctaSpace::Response] - def find(node_id:, job_id:) - get("/idle_jobs/#{encode(node_id)}/#{encode(job_id)}") - end - - # Fetch idle job logs - # GET /idle_jobs/:node_id/:job_id/logs - # @param node_id [Integer, String] - # @param job_id [Integer, String] - # @return [OctaSpace::Response] - def logs(node_id:, job_id:) - get("/idle_jobs/#{encode(node_id)}/#{encode(job_id)}/logs") - end - end - end -end diff --git a/lib/octaspace/resources/network.rb b/lib/octaspace/resources/network.rb index 61e6365..173a4f2 100644 --- a/lib/octaspace/resources/network.rb +++ b/lib/octaspace/resources/network.rb @@ -2,16 +2,18 @@ module OctaSpace module Resources - # Network information endpoint + # Public platform statistics; no API key needed # # @example - # client.network.info + # client.network.info.nodes.online class Network < Base - # Fetch combined network information (blockchain, market, nodes, power, etc.) + # Platform statistics snapshot. # GET /network - # @return [OctaSpace::Response] + # + # Raises {OctaSpace::ServiceUnavailableError} while it is unavailable. + # @return [OctaSpace::Types::Network] def info - get("/network", auth: :none) + Types::Network.new(get("/network", auth: :none).data) end end end diff --git a/lib/octaspace/resources/nodes.rb b/lib/octaspace/resources/nodes.rb deleted file mode 100644 index e9595a2..0000000 --- a/lib/octaspace/resources/nodes.rb +++ /dev/null @@ -1,63 +0,0 @@ -# frozen_string_literal: true - -module OctaSpace - module Resources - # Node-related API endpoints - # - # @example - # client.nodes.list - # client.nodes.find(123) - # client.nodes.reboot(123) - # client.nodes.update_prices(123, gpu_hour: 0.5, cpu_hour: 0.1) - class Nodes < Base - # List all nodes - # GET /nodes - # @param params [Hash] optional filter/pagination params - # @return [OctaSpace::Response] - def list(**params) - get("/nodes", params:) - end - - # Fetch a single node by ID - # GET /nodes/:id - # @param id [Integer, String] - # @return [OctaSpace::Response] - def find(id) - get("/nodes/#{encode(id)}") - end - - # Download node identity file (binary response) - # GET /nodes/:id/ident - # @param id [Integer, String] - # @return [OctaSpace::Response] - def download_ident(id) - get("/nodes/#{encode(id)}/ident") - end - - # Download node logs (binary response) - # GET /nodes/:id/logs - # @param id [Integer, String] - # @return [OctaSpace::Response] - def download_logs(id) - get("/nodes/#{encode(id)}/logs") - end - - # Update node pricing - # PATCH /nodes/:id/prices - # @param id [Integer, String] - # @param prices [Hash] e.g. { gpu_hour: 0.5, cpu_hour: 0.1 } - # @return [OctaSpace::Response] - def update_prices(id, **prices) - patch("/nodes/#{encode(id)}/prices", body: prices) - end - - # Reboot a node - # GET /nodes/:id/reboot - # @param id [Integer, String] - # @return [OctaSpace::Response] - def reboot(id) - get("/nodes/#{encode(id)}/reboot") - end - end - end -end diff --git a/lib/octaspace/resources/stats.rb b/lib/octaspace/resources/stats.rb new file mode 100644 index 0000000..82bd497 --- /dev/null +++ b/lib/octaspace/resources/stats.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module OctaSpace + module Resources + # Public 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 + class Stats < Base + # GPU models on the market now, most common first. + # GET /stats/gpu/current + # @param verified [String, Symbol, nil] "all" (default), "yes" or "no" + # @return [OctaSpace::Types::GpuMarket] + def gpu_current(verified: nil) + Types::GpuMarket.new(fetch("/stats/gpu/current", verified: verified)) + end + + # GPU market over time, by model. Defaults to the last day with hourly + # points. Invalid ranges (step < 300, too many points, from after to) + # raise {OctaSpace::ValidationError}. + # GET /stats/gpu/history + # + # @param gpu_name [String, Array, nil] model name(s), or "all" + # @param verified [String, Symbol, nil] "all", "yes" or "no" + # @param from [Time, Integer, nil] range start (Unix seconds); the API's +start+ + # @param to [Time, Integer, nil] range end (Unix seconds); the API's +end+ + # @param step [Integer, nil] seconds between points, at least 300 (default 3600) + # @return [Hash{String=>OctaSpace::Types::GpuHistory}] by model name + def gpu_history(gpu_name: nil, verified: nil, from: nil, to: nil, step: nil) + data = fetch("/stats/gpu/history", + gpu_name: gpu_name.is_a?(Array) ? gpu_name.join(",") : gpu_name, + verified: verified, start: unix(from), end: unix(to), step: step) + data.to_h.transform_values { |history| Types::GpuHistory.wrap(history) } + end + + # GPUs by city and model (GeoIP), without per-node detail. + # GET /stats/gpu/locations + # @return [Array] + def gpu_locations + Types::GpuLocation.wrap_all(fetch("/stats/gpu/locations")) + end + + private + + def fetch(path, **params) + JsonNull.null_strings!(get(path, params: params, auth: :none).data) + end + + def unix(value) = value.respond_to?(:to_time) ? value.to_time.to_i : value + end + end +end diff --git a/lib/octaspace/types/account.rb b/lib/octaspace/types/account.rb index 0ae11e5..3d98508 100644 --- a/lib/octaspace/types/account.rb +++ b/lib/octaspace/types/account.rb @@ -2,10 +2,23 @@ module OctaSpace module Types - # Value object representing an OctaSpace account - Account = Data.define(:account_uuid, :email, :avatar, :balance) do - # @return [String] - def to_s = "OctaSpace Account <#{email}>" + # Account balance (+GET /accounts/balance+). It can be negative. + class Balance < Model + # @!attribute [r] balance + # @return [Integer] amount in units of 10^-decimals of {#currency} + # @!attribute [r] currency + # @return [String] "USD" + # @!attribute [r] decimals + # @return [Integer] decimal places of {#balance} (6: micro-USD) + attribute :balance, :currency, :decimals + + # @return [Rational] the balance in currency units, exactly + def to_usd = Money.to_usd(balance, decimals: decimals) + + # @return [String] e.g. "$2.147661" + def formatted = Money.format(balance, decimals: decimals) + + def negative? = balance.negative? end end end diff --git a/lib/octaspace/types/balance.rb b/lib/octaspace/types/balance.rb deleted file mode 100644 index 2d6f315..0000000 --- a/lib/octaspace/types/balance.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -module OctaSpace - module Types - # Value object representing an account balance - Balance = Data.define(:amount, :currency) do - # @return [String] - def to_s = "#{amount} #{currency}" - end - end -end diff --git a/lib/octaspace/types/hosting.rb b/lib/octaspace/types/hosting.rb new file mode 100644 index 0000000..9e33679 --- /dev/null +++ b/lib/octaspace/types/hosting.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "time" + +module OctaSpace + module Types + # Prices of a hosted node, integer micro-USD + class NodePrices < Model + # @!attribute [r] base + # @return [Integer] per hour + # @!attribute [r] storage + # @return [Integer] per GB of disk per hour + # @!attribute [r] traffic + # @return [Integer] per GB of traffic + # @!attribute [r] min_rent_minutes + # @return [Integer] minimum rent, 0..60 (0 = none) + attribute :base, :storage, :traffic, :min_rent_minutes + end + + # Whether a node is offered for a service, and why not + class ServiceOffer < Model + # @!attribute [r] reason + # @return [String, nil] e.g. "ports_closed" + attribute :offered, :reason + + def offered? = offered == true + end + + # A verification or benchmark that is running on a node + class NodeActivity < Model + # @!attribute [r] kind + # @return [String] "verification" or "benchmark" + # @!attribute [r] started_at + # @return [Integer, nil] Unix seconds + attribute :kind, :step, :started_at + + # @return [Time, nil] + def started_time = started_at.is_a?(Numeric) ? Time.at(started_at) : nil + end + + # Size and use of memory or disk, bytes + class ResourceUsage < Model + attribute :size, :free, :used, :used_percent + end + + # A GPU as the node reports it; fields beyond these via #[] + class HostingGpu < Gpu + attribute :mem_free_mb, :fan_speed, :gpu_temperature, :gpu_utilization, :power_draw_watt, :pstate + end + + # Hardware and OS of a hosted node + class HostingSystem < Model + attribute :cpu_cores, :cpu_model_name, :cpu_load_percent, :os_version + attribute :memory, :disk, model: ResourceUsage + attribute :gpus, model: HostingGpu, many: true + end + + # Location of a node (GeoIP) + class Location < Model + attribute :city, :country, :latitude, :longitude + end + + # An online node of the account (+GET /hosting/nodes+) + class HostingNode < Model + # @!attribute [r] number + # @return [String] node number, e.g. "M95S-WZG8-7" (the one renters see) + # @!attribute [r] status + # @return [String] "idle", "rented", "verification", "benchmark" or "unknown" + # @!attribute [r] verified + # @return [Boolean, nil] passed verification; nil while unknown + # @!attribute [r] osn + # @return [String] node software version + # @!attribute [r] online_at + # @return [String] ISO 8601; see {#online_time} + # @!attribute [r] uptime + # @return [Integer] seconds since the machine booted + # @!attribute [r] uptime_percent + # @return [Float] online over the last 30 days (97 and above show as 100) + attribute :number, :status, :maintenance, :verified, :ip, :osn, :online_at, :uptime, :uptime_percent + attribute :activity, model: NodeActivity + attribute :prices, model: NodePrices + attribute :system, model: HostingSystem + attribute :location, model: Location + + def maintenance? = maintenance == true + def verified? = verified == true + def idle? = status == "idle" + def rented? = status == "rented" + + # @return [Hash{String=>ServiceOffer}] by service ("compute", "render", …) + def services + @services ||= self["services"].to_h.transform_values { |offer| ServiceOffer.wrap(offer) } + end + + # @param service [String, Symbol] + # @return [Boolean] whether the node is offered for this service + def offered?(service) = services[service.to_s]&.offered? || false + + # @return [Time, nil] + def online_time + online_at.is_a?(String) ? Time.iso8601(online_at) : nil + rescue ArgumentError + nil + end + end + end +end diff --git a/lib/octaspace/types/network.rb b/lib/octaspace/types/network.rb new file mode 100644 index 0000000..f784727 --- /dev/null +++ b/lib/octaspace/types/network.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module OctaSpace + module Types + # Platform statistics snapshot (+GET /network+), refreshed every few minutes + class Network < Model + # Node counts + class Nodes < Model + attribute :online, :available, :rented, :countries + end + + # Total hardware of online nodes; +memory+ and +disk+ in bytes + class Power < Model + attribute :cpu_cores, :disk, :gpus, :memory, :tflops + end + + # Rendered frames of one week + class WeekFrames < Model + attribute :week, :frames + end + + # Render farm counters + class Render < Model + attribute :frames_24h, :frames_total + attribute :frames_by_week, model: WeekFrames, many: true + end + + # Session counters + class Sessions < Model + attribute :running, :total, :last_24h + end + + # User counters + class Users < Model + attribute :total, :last_24h + end + + # VPN node counters + class Vpn < Model + attribute :nodes, :countries + end + + # @!attribute [r] at + # @return [Integer] snapshot time, Unix seconds; see {#time} + attribute :at + attribute :nodes, model: Nodes + attribute :power, model: Power + attribute :render, model: Render + attribute :sessions, model: Sessions + attribute :users, model: Users + attribute :vpn, model: Vpn + + # @return [Time, nil] + def time = at.is_a?(Numeric) ? Time.at(at) : nil + end + end +end diff --git a/lib/octaspace/types/node.rb b/lib/octaspace/types/node.rb deleted file mode 100644 index 91647b8..0000000 --- a/lib/octaspace/types/node.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -module OctaSpace - module Types - # Value object representing a compute node - Node = Data.define(:id, :ip, :state, :location, :prices, :system) do - # @return [Boolean] - def online? = state == "online" - def offline? = !online? - end - end -end diff --git a/lib/octaspace/types/stats.rb b/lib/octaspace/types/stats.rb new file mode 100644 index 0000000..55120c5 --- /dev/null +++ b/lib/octaspace/types/stats.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module OctaSpace + module Types + # Price percentiles, micro-USD per GPU-hour; nil where there is no data + class PricePercentiles < Model + attribute :p10, :p25, :p50, :p75, :p90 + end + + # One GPU model on the market now + class GpuMarketModel < Model + # @!attribute [r] total + # @return [Integer] GPUs (not nodes) of this model + # @!attribute [r] usage + # @return [Float] percent rented now + # @!attribute [r] usage_30d + # @return [Float, nil] average percent rented over 30 days + attribute :model, :total, :available, :rented, :usage, :usage_30d, :ai_score, :tflops, :tflops_per_dollar + attribute :available_price, :rented_price, model: PricePercentiles + end + + # GPU market now (+GET /stats/gpu/current+), most common models first + class GpuMarket < Model + # @!attribute [r] at + # @return [Integer] snapshot time, Unix seconds + attribute :at + attribute :gpus, model: GpuMarketModel, many: true + + # @return [Time, nil] + def time = at.is_a?(Numeric) ? Time.at(at) : nil + end + + # Parallel arrays of GPU counts + class SupplyDemand < Model + attribute :total, :available, :rented + end + + # Parallel arrays of price percentiles + class PriceSeries < Model + attribute :p10, :p25, :p50, :p75, :p90 + end + + # One point of a model's history, see {GpuHistory#points} + GpuHistoryPoint = Data.define(:ts, :total, :available, :rented, :available_price, :rented_price) do + # @return [Time] + def time = Time.at(ts) + end + + # History of one GPU model (+GET /stats/gpu/history+): parallel arrays, + # one value per entry of {#timestamps} + class GpuHistory < Model + # @!attribute [r] timestamps + # @return [Array] Unix seconds + attribute :timestamps, :tflops + attribute :supply_demand, model: SupplyDemand + attribute :available_price, :rented_price, model: PriceSeries + + # Turns the parallel arrays into one point per timestamp, e.g. for a chart. + # @return [Array] + def points + Array(timestamps).each_with_index.map do |ts, i| + GpuHistoryPoint.new( + ts: ts, + total: series_at(supply_demand, :total, i), + available: series_at(supply_demand, :available, i), + rented: series_at(supply_demand, :rented, i), + available_price: percentiles_at(available_price, i), + rented_price: percentiles_at(rented_price, i) + ) + end + end + + private + + def series_at(model, key, i) = model&.public_send(key)&.at(i) + + def percentiles_at(series, i) + PricePercentiles.new(%w[p10 p25 p50 p75 p90].to_h { |p| [p, series_at(series, p, i)] }) + end + end + + # GPUs of one model in one city (+GET /stats/gpu/locations+) + class GpuLocation < Model + # @!attribute [r] gpus + # @return [Integer] GPUs of this model in the city + # @!attribute [r] verified + # @return [Integer] of them on verified nodes + attribute :city, :country, :latitude, :longitude, :model, :gpus, :rented, :verified + end + end +end diff --git a/test/fixtures/accounts/balance.json b/test/fixtures/accounts/balance.json deleted file mode 100644 index afbd637..0000000 --- a/test/fixtures/accounts/balance.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "balance": "1000000000000000000", - "currency": "OCTA" -} diff --git a/test/fixtures/accounts/show.json b/test/fixtures/accounts/show.json deleted file mode 100644 index 603f897..0000000 --- a/test/fixtures/accounts/show.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "account_uuid": "acc-123-abc", - "email": "user@example.com", - "avatar": "https://example.com/avatar.png", - "balance": "1000000000000000000" -} diff --git a/test/fixtures/apps/index.json b/test/fixtures/apps/index.json deleted file mode 100644 index ab250f0..0000000 --- a/test/fixtures/apps/index.json +++ /dev/null @@ -1,26 +0,0 @@ -[ - { - "id": 1, - "uuid": "249b4cb3-3db1-4c06-98a4-772ba88cd81c", - "name": "Stable Diffusion", - "version": "2.1", - "category": "AI", - "image": "ubuntu:24.04", - "docker_image": "octaspace/stable-diffusion:2.1", - "min_vram_gb": 8, - "ports": "[5000,6000]", - "http_ports": "[8080]" - }, - { - "id": 2, - "uuid": "a7f5f354-5d7a-4f4e-b927-a4f18f3eb241", - "name": "ComfyUI", - "version": "1.0", - "category": "AI", - "image": "ubuntu:22.04", - "docker_image": "octaspace/comfyui:latest", - "min_vram_gb": 6, - "ports": "[]", - "http_ports": "[]" - } -] diff --git a/test/fixtures/idle_jobs/index.json b/test/fixtures/idle_jobs/index.json deleted file mode 100644 index 62d7c5d..0000000 --- a/test/fixtures/idle_jobs/index.json +++ /dev/null @@ -1,18 +0,0 @@ -[ - { - "id": 10, - "node_id": 3, - "command": "python train.py --epochs 10", - "status": "completed", - "created_at": "2026-04-10T12:00:00Z", - "finished_at": "2026-04-10T14:30:00Z" - }, - { - "id": 11, - "node_id": 4, - "command": "bash preprocess.sh", - "status": "running", - "created_at": "2026-04-11T07:00:00Z", - "finished_at": null - } -] diff --git a/test/fixtures/idle_jobs/logs.json b/test/fixtures/idle_jobs/logs.json deleted file mode 100644 index 09872d1..0000000 --- a/test/fixtures/idle_jobs/logs.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "data": [ - {"line": 1, "text": "Epoch 1/10 - loss: 0.842"}, - {"line": 2, "text": "Epoch 2/10 - loss: 0.731"}, - {"line": 3, "text": "Training complete."} - ] -} diff --git a/test/fixtures/idle_jobs/show.json b/test/fixtures/idle_jobs/show.json deleted file mode 100644 index b796a5e..0000000 --- a/test/fixtures/idle_jobs/show.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "id": 42, - "node_id": 69, - "status": "completed", - "is_up": false, - "created_at": "2026-04-10T12:00:00Z", - "finished_at": "2026-04-10T14:30:00Z" -} diff --git a/test/fixtures/network/index.json b/test/fixtures/network/index.json deleted file mode 100644 index e78ca57..0000000 --- a/test/fixtures/network/index.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "blockchain": { - "blocktime": 12.77, - "circulating_supply": 42115431, - "era": "Vega", - "height": 9299385 - }, - "market_price": 0.104, - "marketplace": { - "gpus": { - "NVIDIA GeForce RTX 3090": { "avg_price": 0.09, "count": 61 }, - "NVIDIA GeForce RTX 4090": { "avg_price": 0.17, "count": 31 } - } - }, - "nodes": { - "online": 98, - "total": 120 - }, - "power": { - "total_tflops": 5100.5, - "utilized_tflops": 3200.0 - }, - "platform": { - "total_jobs": 412 - } -} diff --git a/test/fixtures/network/power.json b/test/fixtures/network/power.json deleted file mode 100644 index 6e10704..0000000 --- a/test/fixtures/network/power.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "total_tflops": 12500.5, - "available_tflops": 1840.0, - "utilization_pct": 85.3 -} diff --git a/test/fixtures/network/stats.json b/test/fixtures/network/stats.json deleted file mode 100644 index 7001b07..0000000 --- a/test/fixtures/network/stats.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "total_nodes": 120, - "online_nodes": 98, - "total_gpu_count": 310, - "available_gpu_count": 45, - "regions": ["EU", "US", "APAC"] -} diff --git a/test/fixtures/next/account_balance.json b/test/fixtures/next/account_balance.json new file mode 100644 index 0000000..9526195 --- /dev/null +++ b/test/fixtures/next/account_balance.json @@ -0,0 +1,5 @@ +{ + "decimals": 6, + "balance": 1234567, + "currency": "USD" +} \ No newline at end of file diff --git a/test/fixtures/next/hosting_nodes.json b/test/fixtures/next/hosting_nodes.json new file mode 100644 index 0000000..e4d97b0 --- /dev/null +++ b/test/fixtures/next/hosting_nodes.json @@ -0,0 +1,248 @@ +[ + { + "status": "idle", + "system": { + "memory": { + "free": 128846458880, + "size": 132114866176, + "used": 3268407296, + "used_percent": 2 + }, + "os_version": "Ubuntu 26.04", + "gpus": [ + { + "core_clock": 2130, + "fan_speed": 0, + "gpu_temperature": 47, + "gpu_utilization": 0, + "mem_clock": 9751, + "mem_free_mb": 24126, + "mem_total_mb": 24576, + "model": "NVIDIA GeForce RTX 3090", + "pcie_link_gen": 4, + "pcie_link_width": 16, + "power_draw_watt": 39.27, + "power_limit": 390, + "pstate": "P8" + }, + { + "core_clock": 2130, + "fan_speed": 0, + "gpu_temperature": 35, + "gpu_utilization": 0, + "mem_clock": 9751, + "mem_free_mb": 24126, + "mem_total_mb": 24576, + "model": "NVIDIA GeForce RTX 3090", + "pcie_link_gen": 4, + "pcie_link_width": 16, + "power_draw_watt": 24.47, + "power_limit": 390, + "pstate": "P8" + }, + { + "core_clock": 2130, + "fan_speed": 0, + "gpu_temperature": 35, + "gpu_utilization": 0, + "mem_clock": 9751, + "mem_free_mb": 24126, + "mem_total_mb": 24576, + "model": "NVIDIA GeForce RTX 3090", + "pcie_link_gen": 4, + "pcie_link_width": 16, + "power_draw_watt": 29.33, + "power_limit": 390, + "pstate": "P8" + } + ], + "disk": { + "free": 1193761476608, + "size": 1999420977152, + "used": 805659500544, + "used_percent": 41 + }, + "cpu_model_name": "AMD Ryzen Threadripper 7960X 24-Cores", + "cpu_cores": 48, + "cpu_load_percent": 0 + }, + "ip": "203.0.113.10", + "number": "M95S-WZG8-7", + "location": { + "city": "Mukachevo", + "country": "Ukraine", + "latitude": 48.4425, + "longitude": 22.718 + }, + "services": { + "render": { + "reason": null, + "offered": true + }, + "compute": { + "reason": null, + "offered": true + } + }, + "uptime": 170542, + "verified": true, + "uptime_percent": 96.2168089037879, + "prices": { + "base": 100, + "storage": 100, + "min_rent_minutes": 0, + "traffic": 100 + }, + "osn": "1.0.0-pre35", + "online_at": "2026-11-01T18:43:45Z", + "maintenance": false, + "activity": null + }, + { + "status": "idle", + "system": { + "memory": { + "free": 63785484288, + "size": 65173594112, + "used": 1388109824, + "used_percent": 2 + }, + "os_version": "Ubuntu 26.04", + "gpus": [ + { + "core_clock": 2130, + "fan_speed": 0, + "gpu_temperature": 33, + "gpu_utilization": 0, + "mem_clock": 9751, + "mem_free_mb": 24123, + "mem_total_mb": 24576, + "model": "NVIDIA GeForce RTX 3090", + "pcie_link_gen": 3, + "pcie_link_width": 16, + "power_draw_watt": 23.06, + "power_limit": 390, + "pstate": "P8" + } + ], + "disk": { + "free": 773940592640, + "size": 792034541568, + "used": 18093948928, + "used_percent": 3 + }, + "cpu_model_name": "AMD Ryzen 5 5600X 6-Core Processor", + "cpu_cores": 12, + "cpu_load_percent": 0 + }, + "ip": "203.0.113.11", + "number": "7R2S-S05E-S", + "location": { + "city": "Mukachevo", + "country": "Ukraine", + "latitude": 48.4425, + "longitude": 22.718 + }, + "services": { + "render": { + "reason": null, + "offered": true + }, + "compute": { + "reason": null, + "offered": true + } + }, + "uptime": 258040, + "verified": true, + "uptime_percent": 92.03747157535116, + "prices": { + "base": 100000, + "storage": 0, + "min_rent_minutes": 10, + "traffic": 100 + }, + "osn": "1.0.0-pre34", + "online_at": "2026-11-01T18:43:44Z", + "maintenance": false, + "activity": null + }, + { + "status": "idle", + "system": { + "memory": { + "free": 96366718976, + "size": 97915236352, + "used": 1548517376, + "used_percent": 2 + }, + "os_version": "Ubuntu 26.04", + "gpus": [ + { + "core_clock": 2850, + "fan_speed": "null", + "gpu_temperature": 27, + "gpu_utilization": 0, + "mem_clock": "null", + "mem_free_mb": "null", + "mem_total_mb": 12288, + "model": "Arc B580", + "pcie_link_gen": 4, + "pcie_link_width": 8, + "power_draw_watt": "null", + "power_limit": "null" + }, + { + "core_clock": 2850, + "fan_speed": "null", + "gpu_temperature": 51, + "gpu_utilization": 0, + "mem_clock": "null", + "mem_free_mb": "null", + "mem_total_mb": 12288, + "model": "Arc B580", + "pcie_link_gen": 4, + "pcie_link_width": 4, + "power_draw_watt": "null", + "power_limit": "null" + } + ], + "disk": { + "free": 1439272214528, + "size": 1479966982144, + "used": 40694767616, + "used_percent": 3 + }, + "cpu_model_name": "AMD Ryzen 7 7700 8-Core Processor", + "cpu_cores": 16, + "cpu_load_percent": 0 + }, + "ip": "203.0.113.12", + "number": "WJFM-MYWR-9", + "location": { + "city": "Mukachevo", + "country": "Ukraine", + "latitude": 48.4425, + "longitude": 22.718 + }, + "services": { + "compute": { + "reason": null, + "offered": true + } + }, + "uptime": 32390, + "verified": true, + "uptime_percent": 83.78341416810808, + "prices": { + "base": 100, + "storage": 100, + "min_rent_minutes": 0, + "traffic": 100 + }, + "osn": "1.0.0-pre34", + "online_at": "2026-11-01T18:43:44Z", + "maintenance": false, + "activity": null + } +] \ No newline at end of file diff --git a/test/fixtures/next/network.json b/test/fixtures/next/network.json new file mode 100644 index 0000000..b77d742 --- /dev/null +++ b/test/fixtures/next/network.json @@ -0,0 +1,34 @@ +{ + "nodes": { + "available": 2, + "rented": 1, + "countries": 1, + "online": 3 + }, + "at": 1793566134, + "users": { + "total": 1, + "last_24h": 0 + }, + "render": { + "frames_24h": 0, + "frames_by_week": [], + "frames_total": 6588996 + }, + "vpn": { + "nodes": 0, + "countries": 0 + }, + "sessions": { + "running": 1, + "total": 87, + "last_24h": 4 + }, + "power": { + "memory": 295203696640, + "gpus": 6, + "disk": 4271422500864, + "cpu_cores": 76, + "tflops": 142.4 + } +} \ No newline at end of file diff --git a/test/fixtures/next/stats_gpu_current.json b/test/fixtures/next/stats_gpu_current.json new file mode 100644 index 0000000..9183ed8 --- /dev/null +++ b/test/fixtures/next/stats_gpu_current.json @@ -0,0 +1,55 @@ +{ + "at": 1793566134, + "gpus": [ + { + "total": 4, + "usage": 25.0, + "available": 3, + "ai_score": 36621, + "rented": 1, + "model": "NVIDIA GeForce RTX 3090", + "available_price": { + "p10": 33, + "p25": 33, + "p50": 33, + "p75": 33, + "p90": 33 + }, + "rented_price": { + "p10": 100000, + "p25": 100000, + "p50": 100000, + "p75": 100000, + "p90": 100000 + }, + "tflops": 35.6, + "tflops_per_dollar": 1078787.88, + "usage_30d": 11.9 + }, + { + "total": 2, + "usage": 0.0, + "available": 2, + "ai_score": 0, + "rented": 0, + "model": "Arc B580", + "available_price": { + "p10": 50, + "p25": 50, + "p50": 50, + "p75": 50, + "p90": 50 + }, + "rented_price": { + "p10": "null", + "p25": "null", + "p50": "null", + "p75": "null", + "p90": "null" + }, + "tflops": "null", + "tflops_per_dollar": "null", + "usage_30d": 31.39 + } + ] +} \ No newline at end of file diff --git a/test/fixtures/next/stats_gpu_history.json b/test/fixtures/next/stats_gpu_history.json new file mode 100644 index 0000000..6797de5 --- /dev/null +++ b/test/fixtures/next/stats_gpu_history.json @@ -0,0 +1,160 @@ +{ + "Arc B580": { + "available_price": { + "p10": [ + 50, + 50, + 50 + ], + "p25": [ + 50, + 50, + 50 + ], + "p50": [ + 50, + 50, + 50 + ], + "p75": [ + 50, + 50, + 50 + ], + "p90": [ + 50, + 50, + 50 + ] + }, + "rented_price": { + "p10": [ + "null", + 50, + 50 + ], + "p25": [ + "null", + 50, + 50 + ], + "p50": [ + "null", + 50, + 50 + ], + "p75": [ + "null", + 50, + 50 + ], + "p90": [ + "null", + 50, + 50 + ] + }, + "tflops": "null", + "supply_demand": { + "total": [ + 2.0, + 2.0, + 2.0 + ], + "available": [ + 2.0, + 1.57, + 0.55 + ], + "rented": [ + 0.0, + 0.43, + 1.45 + ] + }, + "timestamps": [ + 1793555368, + 1793558968, + 1793562568 + ] + }, + "NVIDIA GeForce RTX 3090": { + "available_price": { + "p10": [ + 33, + 33, + 33 + ], + "p25": [ + 33, + 33, + 33 + ], + "p50": [ + 33, + 33, + 33 + ], + "p75": [ + 25025, + 14314, + 33 + ], + "p90": [ + 70010, + 40020, + 33 + ] + }, + "rented_price": { + "p10": [ + "null", + 100000, + 100000 + ], + "p25": [ + "null", + 100000, + 100000 + ], + "p50": [ + "null", + 100000, + 100000 + ], + "p75": [ + "null", + 100000, + 100000 + ], + "p90": [ + "null", + 100000, + 100000 + ] + }, + "tflops": 35.6, + "supply_demand": { + "total": [ + 4.0, + 4.0, + 4.0 + ], + "available": [ + 4.0, + 3.57, + 3.0 + ], + "rented": [ + 0.0, + 0.43, + 1.0 + ] + }, + "timestamps": [ + 1793555368, + 1793558968, + 1793562568 + ] + } +} \ No newline at end of file diff --git a/test/fixtures/next/stats_gpu_locations.json b/test/fixtures/next/stats_gpu_locations.json new file mode 100644 index 0000000..a8cb635 --- /dev/null +++ b/test/fixtures/next/stats_gpu_locations.json @@ -0,0 +1,22 @@ +[ + { + "city": "Mukachevo", + "country": "Ukraine", + "verified": 2, + "gpus": 2, + "rented": 0, + "model": "Arc B580", + "longitude": 22.718, + "latitude": 48.4425 + }, + { + "city": "Mukachevo", + "country": "Ukraine", + "verified": 4, + "gpus": 4, + "rented": 1, + "model": "NVIDIA GeForce RTX 3090", + "longitude": 22.718, + "latitude": 48.4425 + } +] \ No newline at end of file diff --git a/test/fixtures/nodes/index.json b/test/fixtures/nodes/index.json deleted file mode 100644 index 6d6dc9d..0000000 --- a/test/fixtures/nodes/index.json +++ /dev/null @@ -1,18 +0,0 @@ -[ - { - "id": 1, - "ip": "192.168.1.1", - "state": "online", - "location": {"country": "DE", "city": "Frankfurt"}, - "prices": {"gpu_hour": 0.5, "cpu_hour": 0.1}, - "system": {"gpu": "RTX 4090", "cpu": "AMD EPYC", "ram": 128} - }, - { - "id": 2, - "ip": "192.168.1.2", - "state": "offline", - "location": {"country": "US", "city": "New York"}, - "prices": {"gpu_hour": 0.4, "cpu_hour": 0.08}, - "system": {"gpu": "RTX 3090", "cpu": "Intel Xeon", "ram": 64} - } -] diff --git a/test/fixtures/nodes/show.json b/test/fixtures/nodes/show.json deleted file mode 100644 index 8cd8a68..0000000 --- a/test/fixtures/nodes/show.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "id": 1, - "ip": "192.168.1.1", - "state": "online", - "location": {"country": "DE", "city": "Frankfurt"}, - "prices": {"gpu_hour": 0.5, "cpu_hour": 0.1}, - "system": {"gpu": "RTX 4090", "cpu": "AMD EPYC", "ram": 128} -} diff --git a/test/octaspace/client_test.rb b/test/octaspace/client_test.rb index d7ea647..3589ead 100644 --- a/test/octaspace/client_test.rb +++ b/test/octaspace/client_test.rb @@ -5,14 +5,10 @@ class OctaSpace::ClientTest < Minitest::Test def test_initializes_all_resources client = test_client - assert_instance_of OctaSpace::Resources::Accounts, client.accounts - assert_instance_of OctaSpace::Resources::Nodes, client.nodes - 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::Compute, client.compute - assert_instance_of OctaSpace::Resources::Render, client.render - assert_instance_of OctaSpace::Resources::IdleJobs, client.idle_jobs + {accounts: "Accounts", compute: "Compute", hosting: "Hosting", network: "Network", + render: "Render", sessions: "Sessions", stats: "Stats"}.each do |reader, name| + assert_instance_of OctaSpace::Resources.const_get(name), client.public_send(reader) + end end def test_uses_standard_transport_by_default diff --git a/test/octaspace/money_test.rb b/test/octaspace/money_test.rb new file mode 100644 index 0000000..3f6566f --- /dev/null +++ b/test/octaspace/money_test.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "test_helper" + +class OctaSpace::MoneyTest < Minitest::Test + Money = OctaSpace::Money + + def test_format + assert_equal "$0.000100", Money.format(100) + assert_equal "$0.000000", Money.format(0) + assert_equal "-$1.250000", Money.format(-1_250_000) + assert_equal "$9007199254.740993", Money.format(9_007_199_254_740_993) + assert_equal "$12", Money.format(12, decimals: 0) + end + + def test_to_usd_is_exact + assert_equal Rational(1, 10_000), Money.to_usd(100) + assert_equal Rational(-5, 4), Money.to_usd(-1_250_000) + end + + def test_from_usd + assert_equal 250_000, Money.from_usd("0.25") + assert_equal 250_000, Money.from_usd("$0.25") + assert_equal 100, Money.from_usd(0.0001) + assert_equal 1_000_000, Money.from_usd(1) + assert_equal 333_333, Money.from_usd(Rational(1, 3)) + assert_equal 1, Money.from_usd("0.0000005") + end + + def test_rejects_bad_input + assert_raises(ArgumentError) { Money.format(1.5) } + assert_raises(ArgumentError) { Money.from_usd("abc") } + assert_raises(ArgumentError) { Money.from_usd(Float::NAN) } + assert_raises(ArgumentError) { Money.from_usd(nil) } + end +end diff --git a/test/octaspace/payload_helpers_test.rb b/test/octaspace/payload_helpers_test.rb deleted file mode 100644 index 58593ac..0000000 --- a/test/octaspace/payload_helpers_test.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -class OctaSpace::PayloadHelpersTest < Minitest::Test - def test_parse_port_list_accepts_arrays - assert_equal [5000, 6000], OctaSpace::PayloadHelpers.parse_port_list([5000, 6000]) - end - - def test_parse_port_list_parses_json_strings - assert_equal [8080, 8888], OctaSpace::PayloadHelpers.parse_port_list("[8080,8888]") - end - - def test_parse_port_list_returns_empty_array_for_invalid_string - assert_equal [], OctaSpace::PayloadHelpers.parse_port_list("not-json") - end - - def test_parse_port_list_returns_empty_array_for_nil - assert_equal [], OctaSpace::PayloadHelpers.parse_port_list(nil) - end -end diff --git a/test/octaspace/resources/accounts_test.rb b/test/octaspace/resources/accounts_test.rb index b6f0002..55ff6d4 100644 --- a/test/octaspace/resources/accounts_test.rb +++ b/test/octaspace/resources/accounts_test.rb @@ -3,46 +3,28 @@ require "test_helper" class OctaSpace::AccountsResourceTest < Minitest::Test - def setup - super - @client = test_client + def test_balance + stub_get("/accounts/balance", fixture_path: "next/account_balance.json") + balance = test_client.accounts.balance + + assert_equal 1_234_567, balance.balance + assert_equal "USD", balance.currency + assert_equal 6, balance.decimals + assert_equal Rational(1_234_567, 1_000_000), balance.to_usd + assert_equal "$1.234567", balance.formatted + refute balance.negative? end - def test_profile_returns_response - stub_get("/accounts", fixture_path: "accounts/show.json") - response = @client.accounts.profile - assert response.success? - assert_equal "acc-123-abc", response.data["account_uuid"] - assert_equal "user@example.com", response.data["email"] - end - - def test_balance_returns_response - stub_get("/accounts/balance", fixture_path: "accounts/balance.json") - response = @client.accounts.balance - assert response.success? - assert response.data.key?("balance") - end - - def test_generate_wallet_returns_success - stub_post("/accounts", status: 201, body: '{"wallet":"0xabc123","network":"ETH"}') - response = @client.accounts.generate_wallet - assert response.success? - end - - def test_profile_raises_authentication_error_on_401 - stub_error(:get, "/accounts", status: 401, message: "Unauthorized") - assert_raises(OctaSpace::AuthenticationError) { @client.accounts.profile } - end + def test_negative_balance_and_other_decimals + stub_request(:get, "#{StubHelpers::BASE_URL}/accounts/balance") + .to_return(status: 200, body: '{"balance":-1250,"currency":"USD","decimals":3}') + balance = test_client.accounts.balance - def test_profile_raises_rate_limit_error_on_429 - stub_request(:get, "#{StubHelpers::BASE_URL}/accounts") - .to_return(status: 429, body: "{}", headers: {"Retry-After" => "60"}.merge(json_headers)) - error = assert_raises(OctaSpace::RateLimitError) { @client.accounts.profile } - assert_equal 60, error.retry_after + assert balance.negative? + assert_equal "-$1.250", balance.formatted end - def test_profile_raises_server_error_on_500 - stub_error(:get, "/accounts", status: 500, message: "Internal Server Error") - assert_raises(OctaSpace::ServerError) { @client.accounts.profile } + def test_balance_requires_key + assert_raises(OctaSpace::AuthenticationError) { OctaSpace::Client.new.accounts.balance } end end diff --git a/test/octaspace/resources/apps_test.rb b/test/octaspace/resources/apps_test.rb deleted file mode 100644 index 9ed104a..0000000 --- a/test/octaspace/resources/apps_test.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -class OctaSpace::AppsResourceTest < Minitest::Test - def setup - super - @client = test_client - end - - def test_list_returns_array - stub_get("/apps", fixture_path: "apps/index.json") - response = @client.apps.list - assert response.success? - assert_kind_of Array, response.data - assert_equal 2, response.data.size - assert_equal "Stable Diffusion", response.data.first["name"] - assert_equal "[5000,6000]", response.data.first["ports"] - assert_equal "[8080]", response.data.first["http_ports"] - end - - def test_list_passes_params - stub_request(:get, "#{StubHelpers::BASE_URL}/apps") - .with(query: {"category" => "AI"}) - .to_return(status: 200, body: "[]", headers: json_headers) - response = @client.apps.list(category: "AI") - assert response.success? - end - - def test_list_raises_authentication_error_on_401 - stub_error(:get, "/apps", status: 401, message: "Unauthorized") - assert_raises(OctaSpace::AuthenticationError) { @client.apps.list } - end -end diff --git a/test/octaspace/resources/hosting_test.rb b/test/octaspace/resources/hosting_test.rb new file mode 100644 index 0000000..decfa15 --- /dev/null +++ b/test/octaspace/resources/hosting_test.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "test_helper" + +class OctaSpace::HostingResourceTest < Minitest::Test + BASE = StubHelpers::BASE_URL + + def setup + super + @client = test_client + end + + def test_nodes + stub_get("/hosting/nodes", fixture_path: "next/hosting_nodes.json") + node = @client.hosting.nodes.find { |n| n.number == "M95S-WZG8-7" } + + assert node.idle? + assert node.verified? + refute node.maintenance? + assert node.offered?(:compute) + assert_nil node.services["compute"].reason + assert_equal 100, node.prices.base + assert_equal 0, node.prices.min_rent_minutes + 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 Time.utc(2026, 11, 1, 18, 43, 45), node.online_time + assert_nil node.activity + end + + def test_nodes_repair_null_strings_in_gpus + stub_request(:get, "#{BASE}/hosting/nodes").to_return(status: 200, + body: '[{"number":"A","system":{"gpus":[{"fan_speed":"null","mem_free_mb":"","power_draw_watt":"12.5"}]}}]') + gpu = @client.hosting.nodes.first.system.gpus.first + + assert_nil gpu.fan_speed + assert_nil gpu.mem_free_mb + assert_in_delta 12.5, gpu.power_draw_watt + end + + def test_set_prices_sends_only_given_fields + stub_request(:patch, "#{BASE}/hosting/nodes/M95S-WZG8-7/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) + end + + def test_set_prices_validates_locally + assert_raises(ArgumentError) { @client.hosting.set_prices("A") } + assert_raises(ArgumentError) { @client.hosting.set_prices("A", base: "100") } + assert_raises(ArgumentError) { @client.hosting.set_prices("A", base: 0.25) } + assert_raises(ArgumentError) { @client.hosting.set_prices("A", gpu_hour: 1) } + end + + def test_set_prices_api_errors + stub_request(:patch, "#{BASE}/hosting/nodes/ZZZZ-ZZZZ-Z/prices").to_return(status: 400, body: '{"message":"Invalid node number"}') + stub_request(:patch, "#{BASE}/hosting/nodes/0000-0000-0/prices").to_return(status: 404, body: '{"message":"Node not found"}') + + assert_raises(OctaSpace::ValidationError) { @client.hosting.set_prices("ZZZZ-ZZZZ-Z", base: 100) } + assert_raises(OctaSpace::NotFoundError) { @client.hosting.set_prices("0000-0000-0", base: 100) } + end +end diff --git a/test/octaspace/resources/idle_jobs_test.rb b/test/octaspace/resources/idle_jobs_test.rb deleted file mode 100644 index 1e66b27..0000000 --- a/test/octaspace/resources/idle_jobs_test.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -class OctaSpace::IdleJobsResourceTest < Minitest::Test - def setup - super - @client = test_client - end - - def test_find_returns_single_job - stub_get("/idle_jobs/69/42", fixture_path: "idle_jobs/show.json") - response = @client.idle_jobs.find(node_id: 69, job_id: 42) - assert response.success? - assert_equal 42, response.data["id"] - assert_equal "completed", response.data["status"] - end - - def test_find_raises_not_found - stub_error(:get, "/idle_jobs/69/99999", status: 404, message: "Job not found") - assert_raises(OctaSpace::NotFoundError) { @client.idle_jobs.find(node_id: 69, job_id: 99999) } - end - - def test_logs_returns_data - stub_get("/idle_jobs/69/42/logs", fixture_path: "idle_jobs/logs.json") - response = @client.idle_jobs.logs(node_id: 69, job_id: 42) - assert response.success? - end - - def test_logs_raises_authentication_error_on_401 - stub_error(:get, "/idle_jobs/69/42", status: 401, message: "Unauthorized") - assert_raises(OctaSpace::AuthenticationError) { @client.idle_jobs.find(node_id: 69, job_id: 42) } - end -end diff --git a/test/octaspace/resources/network_test.rb b/test/octaspace/resources/network_test.rb index a1c9a96..854d32a 100644 --- a/test/octaspace/resources/network_test.rb +++ b/test/octaspace/resources/network_test.rb @@ -3,22 +3,29 @@ require "test_helper" class OctaSpace::NetworkResourceTest < Minitest::Test - def setup - super - @client = test_client + def test_info_without_key + stub_get("/network", fixture_path: "next/network.json") + info = OctaSpace::Client.new.network.info + + assert_equal 3, info.nodes.online + assert_equal 76, info.power.cpu_cores + assert_in_delta 142.4, info.power.tflops + assert_equal 6_588_996, info.render.frames_total + assert_equal [], info.render.frames_by_week + assert_equal 87, info.sessions.total + assert_equal 0, info.vpn.nodes + assert_equal Time.at(1_793_566_134), info.time + assert_requested(:get, "#{StubHelpers::BASE_URL}/network") { |req| !req.headers.key?("Authorization") } end - def test_info_returns_response - stub_get("/network", fixture_path: "network/index.json") - response = @client.network.info - assert response.success? - assert response.data.key?("blockchain") - assert response.data.key?("market_price") - assert response.data.key?("nodes") + def test_info_never_sends_key + stub_get("/network", fixture_path: "next/network.json") + test_client.network.info + assert_requested(:get, "#{StubHelpers::BASE_URL}/network") { |req| !req.headers.key?("Authorization") } end - def test_info_raises_server_error_on_500 - stub_error(:get, "/network", status: 500, message: "Internal Server Error") - assert_raises(OctaSpace::ServerError) { @client.network.info } + def test_unavailable + stub_request(:get, "#{StubHelpers::BASE_URL}/network").to_return(status: 503, body: '{"message":"Network information is unavailable"}') + assert_raises(OctaSpace::ServiceUnavailableError) { OctaSpace::Client.new.network.info } end end diff --git a/test/octaspace/resources/nodes_test.rb b/test/octaspace/resources/nodes_test.rb deleted file mode 100644 index b7160da..0000000 --- a/test/octaspace/resources/nodes_test.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -class OctaSpace::NodesResourceTest < Minitest::Test - def setup - super - @client = test_client - end - - def test_list_returns_array - stub_get("/nodes", fixture_path: "nodes/index.json") - response = @client.nodes.list - assert response.success? - assert_kind_of Array, response.data - assert_equal 2, response.data.size - end - - def test_find_returns_single_node - stub_get("/nodes/1", fixture_path: "nodes/show.json") - response = @client.nodes.find(1) - assert response.success? - assert_equal 1, response.data["id"] - assert_equal "online", response.data["state"] - end - - def test_find_raises_not_found - stub_error(:get, "/nodes/99999", status: 404, message: "Node not found") - assert_raises(OctaSpace::NotFoundError) { @client.nodes.find(99999) } - end - - def test_reboot_returns_success - stub_request(:get, "#{StubHelpers::BASE_URL}/nodes/1/reboot") - .to_return(status: 200, body: "{}", headers: json_headers) - response = @client.nodes.reboot(1) - assert response.success? - end - - def test_update_prices - stub_request(:patch, "#{StubHelpers::BASE_URL}/nodes/1/prices") - .to_return(status: 200, body: "{}", headers: json_headers) - response = @client.nodes.update_prices(1, gpu_hour: 0.5, cpu_hour: 0.1) - assert response.success? - end - - def test_list_passes_params - stub_request(:get, "#{StubHelpers::BASE_URL}/nodes") - .with(query: {"state" => "online"}) - .to_return(status: 200, body: "[]", headers: json_headers) - response = @client.nodes.list(state: "online") - assert response.success? - end -end diff --git a/test/octaspace/resources/stats_test.rb b/test/octaspace/resources/stats_test.rb new file mode 100644 index 0000000..45a86f9 --- /dev/null +++ b/test/octaspace/resources/stats_test.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "test_helper" + +class OctaSpace::StatsResourceTest < Minitest::Test + BASE = StubHelpers::BASE_URL + + def setup + super + @client = OctaSpace::Client.new + end + + def test_gpu_current + stub_request(:get, "#{BASE}/stats/gpu/current").with(query: {"verified" => "yes"}) + .to_return(status: 200, body: fixture("next/stats_gpu_current.json")) + market = @client.stats.gpu_current(verified: :yes) + + gpu = market.gpus.first + assert_equal "NVIDIA GeForce RTX 3090", gpu.model + assert_equal 100_000, gpu.rented_price.p50 + assert_kind_of Time, market.time + end + + def test_gpu_current_null_strings + stub_request(:get, "#{BASE}/stats/gpu/current") + .to_return(status: 200, body: '{"at":1,"gpus":[{"model":"X","tflops":"null","available_price":{"p10":"null"}}]}') + gpu = @client.stats.gpu_current.gpus.first + + assert_nil gpu.tflops + assert_nil gpu.available_price.p10 + end + + def test_gpu_history_params_and_points + from = Time.at(1_793_000_000) + stub_request(:get, "#{BASE}/stats/gpu/history") + .with(query: {"gpu_name" => "Arc B580,NVIDIA GeForce RTX 3090", "start" => "1793000000", "end" => "1793086400", "step" => "3600"}) + .to_return(status: 200, body: fixture("next/stats_gpu_history.json")) + + history = @client.stats.gpu_history(gpu_name: ["Arc B580", "NVIDIA GeForce RTX 3090"], from: from, to: 1_793_086_400, step: 3600) + arc = history.fetch("Arc B580") + assert_instance_of OctaSpace::Types::GpuHistory, arc + assert_nil arc.tflops + + points = arc.points + assert_equal arc.timestamps.size, points.size + assert_equal arc.supply_demand.total.first, points.first.total + assert_equal arc.rented_price.p50, points.map { |p| p.rented_price.p50 } + assert_equal Time.at(arc.timestamps.first), points.first.time + end + + def test_gpu_history_validation_error + stub_request(:get, "#{BASE}/stats/gpu/history").with(query: {"step" => "10"}) + .to_return(status: 400, body: '{"message":"step must be at least 300 seconds"}') + + error = assert_raises(OctaSpace::ValidationError) { @client.stats.gpu_history(step: 10) } + assert_equal "step must be at least 300 seconds", error.message + end + + def test_gpu_locations + stub_get("/stats/gpu/locations", fixture_path: "next/stats_gpu_locations.json") + loc = @client.stats.gpu_locations.first + + assert_equal "Mukachevo", loc.city + assert_equal "Arc B580", loc.model + assert_equal 2, loc.gpus + end +end diff --git a/test/octaspace/types_test.rb b/test/octaspace/types_test.rb deleted file mode 100644 index 2ed6b4f..0000000 --- a/test/octaspace/types_test.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -class OctaSpace::TypesTest < Minitest::Test - # --- Node --- - - def test_node_online - node = OctaSpace::Types::Node.new( - id: 1, ip: "1.2.3.4", state: "online", - location: {"city" => "Amsterdam"}, prices: {}, system: {} - ) - assert node.online? - refute node.offline? - end - - def test_node_offline - node = OctaSpace::Types::Node.new( - id: 2, ip: "5.6.7.8", state: "offline", - location: {}, prices: {}, system: {} - ) - refute node.online? - assert node.offline? - end - - def test_node_equality - attrs = {id: 1, ip: "1.2.3.4", state: "online", location: {}, prices: {}, system: {}} - assert_equal OctaSpace::Types::Node.new(**attrs), OctaSpace::Types::Node.new(**attrs) - end - - def test_node_immutability - node = OctaSpace::Types::Node.new(id: 1, ip: "x", state: "online", location: {}, prices: {}, system: {}) - assert_raises(NoMethodError) { node.id = 99 } - end - - # --- Balance --- - - def test_balance_to_s - balance = OctaSpace::Types::Balance.new(amount: "1000000000000000000", currency: "OCTA") - assert_equal "1000000000000000000 OCTA", balance.to_s - end - - def test_balance_equality - b1 = OctaSpace::Types::Balance.new(amount: "100", currency: "OCTA") - b2 = OctaSpace::Types::Balance.new(amount: "100", currency: "OCTA") - assert_equal b1, b2 - end - - # --- Account --- - - def test_account_to_s - account = OctaSpace::Types::Account.new( - account_uuid: "acc-123", email: "user@example.com", - avatar: nil, balance: "100" - ) - assert_equal "OctaSpace Account ", account.to_s - end -end From e78ca9b619da5282470c0e2344bee4db7eb5d7e6 Mon Sep 17 00:00:00 2001 From: secretpray Date: Tue, 3 Nov 2026 19:35:13 +0200 Subject: [PATCH 7/9] docs!: README, migration guide and smoke test for the next API; 0.3.0 - README rewritten for the next API: API keys, resources table, models, units, batch starts, restart semantics, sessions and SSH, error hierarchy, retry policy, configuration, Rails - docs/migration-next.md: 0.2.x -> 0.3 mapping of client, resources, data, errors and behaviour - CHANGELOG: 0.3.0 (unreleased) - bin/smoke: live checks of every read endpoint; --compute NODE adds a paid start / wait_ready / logs / stop round trip that always stops the session it started - CI: read-only smoke job on pushes (never on pull requests) - version 0.3.0; doc comments no longer point at legacy hosts --- .github/workflows/ci.yml | 24 ++ CHANGELOG.md | 37 +++ PUBLISHING.md | 14 +- README.md | 414 ++++++++++++------------ bin/console | 2 +- bin/smoke | 75 +++++ docs/migration-next.md | 75 +++++ lib/octaspace.rb | 6 +- lib/octaspace/client.rb | 4 +- lib/octaspace/middleware/url_rotator.rb | 6 +- lib/octaspace/version.rb | 2 +- 11 files changed, 431 insertions(+), 228 deletions(-) create mode 100755 bin/smoke create mode 100644 docs/migration-next.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f64d7b..849e145 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,3 +80,27 @@ jobs: # Fails on any advisory for the resolved gems (fresh advisory database each run). - name: bundler-audit run: bundle exec bundle-audit check --update + + smoke: + name: Smoke test (live API) + runs-on: ubuntu-latest + timeout-minutes: 10 + # Only after a merge: pull requests from forks must not reach the live API with the secret. + if: github.event_name == 'push' + needs: test + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: ruby/setup-ruby@de492cf36cbab201753d720ce4f4e336cd688c94 # v1.336.0 + with: + ruby-version: "4.0" + bundler-cache: true + + # Read-only. Without the OCTA_API_KEY secret only the public checks run. + - name: Smoke + env: + OCTA_API_KEY: ${{ secrets.OCTA_API_KEY }} + run: bin/smoke diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dffaf6..696fa9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,43 @@ All notable changes to this project will be 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). +## [0.3.0] - Unreleased + +Moves the SDK to the next-generation OctaSpace API (`https://api-dev.octa.space`). This release is +not compatible with the legacy API; see [docs/migration-next.md](docs/migration-next.md). + +### 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` (with `GpuHistory#points`), `gpu_locations`. +- Typed read-only models (`OctaSpace::Model`, `OctaSpace::Types::*`) that keep unknown fields. +- `OctaSpace::Money` for integer micro-USD. +- `OctaSpace::ConflictError` (409), `SessionStoppedError`, `WaitTimeoutError`. +- `OctaSpace::RateLimit`, `client.rate_limit`, `ApiError#retry_after` / `#rate_limit` / `#body`. +- `bin/smoke` live checks and a CI smoke job; `bundler-audit` in CI; Dependabot. + +### Changed + +- Default base URL is `https://api-dev.octa.space`; authentication is `Authorization: Bearer ` + with `octa_ak_…` keys. +- Resource methods return models, uuids or `nil` instead of `OctaSpace::Response`. +- 400 maps to `ValidationError`; error messages come from the API's `{"message"}`. +- Retries only for GET and the idempotent session stop, honouring `Retry-After` up to 60 s; + failover across `base_urls` only for GET. +- Logging never includes 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. + +### Removed + +- Legacy resources: `nodes`, `apps`, `idle_jobs`, `services` (`mr`, `vpn`, `render`, `session`), + `accounts.profile`, `accounts.generate_wallet`. +- `ProvisionRejectedError`, `PayloadHelpers`, the old `Types::Account/Balance/Node/Session`. +- The Rails playground (`bin/playground`, `test/dummy`), `MockTransport`, the Rails Engine and the + Appraisal matrix. + ## [0.2.0] - 2026-04-16 ### Added diff --git a/PUBLISHING.md b/PUBLISHING.md index 7b419ec..dc3fd7b 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -35,7 +35,7 @@ gem build octaspace.gemspec # builds without warnings Install the built gem outside the project and confirm it loads correctly: ```bash -gem install ./octaspace-0.2.0.gem +gem install ./octaspace-0.3.0.gem ruby -e "require 'octaspace'; p OctaSpace::Client.new" ``` @@ -55,24 +55,24 @@ Use these alternatives to verify the gem before publishing: - **Inspect gem contents:** ```bash - gem unpack octaspace-0.2.0.gem --target=/tmp/octaspace-check + gem unpack octaspace-0.3.0.gem --target=/tmp/octaspace-check find /tmp/octaspace-check -type f | sort rm -rf /tmp/octaspace-check ``` - **Check metadata that RubyGems.org will display:** ```bash - gem specification octaspace-0.2.0.gem + gem specification octaspace-0.3.0.gem ``` - **Install and test in a clean environment:** ```bash - gem install ./octaspace-0.2.0.gem + gem install ./octaspace-0.3.0.gem ruby -e "require 'octaspace'; p OctaSpace::Client.new; puts OctaSpace::VERSION" ``` ## 4. Publish to RubyGems.org ```bash -gem push octaspace-0.2.0.gem +gem push octaspace-0.3.0.gem ``` You will be prompted for credentials on first push. The API key is saved to `~/.gem/credentials`. @@ -80,6 +80,6 @@ You will be prompted for credentials on first push. The API key is saved to `~/. ## 5. Post-publish - Verify the gem page at [rubygems.org/gems/octaspace](https://rubygems.org/gems/octaspace). -- Tag the exact published commit: `git tag v0.2.0 && git push origin v0.2.0`. -- Create the GitHub Release manually from tag `v0.2.0`. +- Tag the exact published commit: `git tag v0.3.0 && git push origin v0.3.0`. +- Create the GitHub Release manually from tag `v0.3.0`. - Bump `lib/octaspace/version.rb` for the next development cycle in a follow-up commit. diff --git a/README.md b/README.md index bfd20ef..cf25435 100644 --- a/README.md +++ b/README.md @@ -4,289 +4,281 @@ [![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) -Official Ruby SDK for the [OctaSpace API](https://api.octa.space/api-docs). +Official Ruby SDK for the next-generation OctaSpace API. + +> **The `next` branch targets the new API**, currently served only by the test stand +> `https://api-dev.octa.space` ([Swagger](https://api-dev.octa.space/swagger)). The production hosts +> (`api.octa.computer`, `api.octa.space`) still serve the legacy API, which the 0.2.x gems support. +> Moving from 0.2.x: see [docs/migration-next.md](docs/migration-next.md). ## Features -- **Resource-oriented API** — `client.nodes.list`, `client.services.session(uuid).stop` -- **Keep-alive mode** — persistent connections via `faraday-net_http_persistent` + `connection_pool` -- **URL rotation / failover** — round-robin across multiple endpoints with per-URL cooldown -- **Retry with exponential backoff + jitter** — configurable retries on transient failures -- **Typed error hierarchy** — 12 exception classes mapped from HTTP status codes -- **`on_request` / `on_response` hooks** — for logging, tracing, APM -- **Rails integration** — Railtie, shared client, graceful shutdown at_exit -- **Ruby ≥ 3.3**; optional Railtie for Rails apps +- **Typed resources** for every endpoint of the API: compute, render, sessions, hosting, account, network, GPU statistics +- **Read-only models** that tolerate new API fields: `machine.node_number`, `machine[:new_field]`, `machine.to_h` +- **Safe retries**: only reads and the idempotent session stop are retried, honouring `Retry-After`; starts that cost money never are +- **Typed errors** for 400/401/403/404/409/429/5xx with `retry_after` and `rate_limit` +- **Rate limit state** from `RateLimit-*` headers: `client.rate_limit` +- **Session helpers**: `wait_ready` with progress, `wait_stopped` +- **Exact money helpers** for integer micro-USD: `OctaSpace::Money` +- **Keep-alive mode** (persistent connections + pool) and **failover** across several base URLs for reads +- **No secrets in logs**: headers are never logged, access URLs are redacted in `inspect` +- Ruby ≥ 3.3; optional Railtie for Rails apps ## Installation ```ruby # Gemfile -gem "octaspace" +gem "octaspace", github: "octaspace/ruby-sdk", branch: "next" # Optional — required for keep_alive: true gem "faraday-net_http_persistent" gem "connection_pool" ``` -```ruby -bundle install -``` +## API key -## Quick Start +Create a key in Cube under **Profile → Credentials**. Keys look like `octa_ak_…` and are sent as +`Authorization: Bearer `. Legacy account tokens are refused by the new API (the SDK says so in +the error message). + +The key is optional: network and GPU statistics never use it, and machine listings work without it +(but the anonymous listing is cached for up to an hour; with a key it is at most 10 seconds old). +Methods that need a key raise `OctaSpace::AuthenticationError` before sending anything when none is +configured. + +## Quick start ```ruby require "octaspace" -# Public endpoints (no API key required) -client = OctaSpace::Client.new -client.network.info # GET /network - -# Authenticated endpoints client = OctaSpace::Client.new(api_key: ENV["OCTA_API_KEY"]) -# Accounts -client.accounts.profile # GET /accounts -client.accounts.balance # GET /accounts/balance - -# Nodes -client.nodes.list # GET /nodes -client.nodes.find(123) # GET /nodes/:id -client.nodes.reboot(123) # GET /nodes/:id/reboot -client.nodes.update_prices(123, gpu_hour: 0.5) # PATCH /nodes/:id/prices - -# Sessions (list) -client.sessions.list # GET /sessions - -# Session proxy — operations on a specific session -session = client.services.session("uuid-here") -session.info # GET /services/:uuid/info -session.logs # GET /services/:uuid/logs -session.logs(recent: true) # GET /services/:uuid/logs?recent=true for finished sessions -session.stop(score: 5) # POST /services/:uuid/stop - -# Services -client.services.mr.list # GET /services/mr (marketplace machine catalog) -client.services.mr.create( - node_id: 1, - disk_size: 10, - image: "ubuntu:24.04", - app: "249b4cb3-3db1-4c06-98a4-772ba88cd81c" -) # POST /services/mr -client.services.vpn.list # GET /services/vpn (VPN relay catalog) -client.services.vpn.create(node_id: 1, subkind: "wg") # POST /services/vpn -client.services.render.list # GET /services/render -client.services.render.create(node_id: 1, disk_size: 100) # POST /services/render +client.accounts.balance.formatted # => "$2.147661" -# Apps -client.apps.list # GET /apps - -# Note: live API may serialize app port lists as JSON strings -# (for example "[]"). The raw response is preserved by the SDK. +machine = client.compute.list.min_by(&:base_price) +uuid = client.compute.start( + node_number: machine.node_number, # e.g. "M95S-WZG8-7" + image: "ubuntu:24.04", + disk_size: 20, + http_ports: [8888], + envs: {"FOO" => "bar"}, + ssh: true +) -# Network -client.network.info # GET /network +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 access, or the SSH proxy -# Idle Jobs -client.idle_jobs.find(node_id: 69, job_id: 42) # GET /idle_jobs/:node_id/:job_id -client.idle_jobs.logs(node_id: 69, job_id: 42) # GET /idle_jobs/:node_id/:job_id/logs +client.sessions.logs(uuid, tail: 100).container +client.sessions.stop(uuid) +client.sessions.wait_stopped(uuid) ``` -## Rails Integration - -### Initializer +## 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` (object) | yes | session uuid | +| `compute.start_batch(items)` | `POST /services/compute` (array) | 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` (object) | yes | session uuid | +| `render.start_batch(items)` | `POST /services/render` (array) | 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 `get` | yes | `Types::Session` | +| `sessions.wait_stopped(uuid, timeout:, interval:)` | polls `get` until 404 | 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` | + +### Models + +Every response is wrapped in a read-only model with readers for the fields of the API spec. Fields +the SDK does not know yet are kept: read them with `model[:field]`, and get the parsed JSON with +`model.to_h`. Models support pattern matching: ```ruby -# config/initializers/octaspace.rb -OctaSpace.configure do |config| - config.api_key = ENV["OCTA_API_KEY"] - config.keep_alive = true - config.pool_size = ENV.fetch("RAILS_MAX_THREADS", 5).to_i - config.logger = Rails.logger +case client.sessions.get(uuid) +in OctaSpace::Types::ComputeSession => s if s.ready? + puts s.public_ip +in {status: "starting", progress:} + puts progress end ``` -### Shared client +`sessions.list` and `sessions.get` return a `ComputeSession` or a `RenderSession` by the `service` +field, and a plain `Types::Session` for a service the SDK does not know yet. + +### Units -`OctaSpace.client` (called without arguments) returns a **lazily-initialized shared client** built from global configuration. It is safe to call it on every request: +| 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), `activity.started_at` | Unix seconds | `time`, `started_time` | +| `online_at` (hosting) | ISO 8601 string | `online_time` | +| `net_down_mbps`, `net_up_mbps` | Mbit/s (0 in listings = not measured) | — | +| `memory`, `disk` in listings | bytes (free) | — | +| `node_hw.disk` in sessions | GB (rented) | — | ```ruby -# app/controllers/application_controller.rb -def octa_client - OctaSpace.client # returns the same instance each time — no new connections -end +OctaSpace::Money.format(100) # => "$0.000100" +OctaSpace::Money.to_usd(1_250_000) # => (5/4) +OctaSpace::Money.from_usd("0.25") # => 250000 +client.hosting.set_prices("M95S-WZG8-7", base: OctaSpace::Money.from_usd("0.25"), min_rent_minutes: 15) ``` -Pass arguments to create a **one-off client** instead (e.g., for per-user API keys): +### Batch starts ```ruby -OctaSpace.client(api_key: current_user.octa_api_key) # new client, not cached +results = client.compute.start_batch([ + {id: "a", node_number: "M95S-WZG8-7", image: "ubuntu:24.04"}, + {id: "b", node_number: "7R2S-S05E-S", image: "ubuntu:24.04"} +]) +results.each { |r| puts r.started? ? "#{r.id}: #{r.uuid}" : "#{r.id}: #{r.reason}" } ``` -### Graceful shutdown +Each item is validated on its own; a failed item is reported in its result, not raised. Items without +an `id` get their position in the array. A single `start` raises instead +(`OctaSpace::ValidationError` with the API's reason). -The Railtie registers an `at_exit` hook that automatically shuts down the shared client's connection pool when the Rails process stops. No manual cleanup needed. - -## Configuration Reference - -| Option | Default | Description | -|---|---|---| -| `api_key` | `nil` | API key sent as `Authorization` header (optional for public endpoints) | -| `base_url` | `https://api.octa.space` | Single API endpoint | -| `base_urls` | `nil` | Multiple endpoints — enables URL rotation | -| `keep_alive` | `false` | Persistent HTTP connections + pool | -| `pool_size` | `5` | Connection pool size (keep-alive mode) | -| `pool_timeout` | `5` | Seconds to wait for a pool slot | -| `idle_timeout` | `60` | Seconds before an idle connection closes | -| `open_timeout` | `10` | Seconds to open TCP connection | -| `read_timeout` | `30` | Seconds to read response | -| `write_timeout` | `30` | Seconds to write request body | -| `max_retries` | `2` | Retry attempts on transient failures | -| `retry_interval` | `0.5` | Base retry interval in seconds | -| `backoff_factor` | `2.0` | Exponential backoff multiplier | -| `ssl_verify` | `true` | Verify SSL certificates | -| `on_request` | `nil` | `callable(req_hash)` — before each request | -| `on_response` | `nil` | `callable(response)` — after each response | -| `logger` | `nil` | Ruby `Logger` instance | -| `log_level` | `:info` | Log level | -| `user_agent` | auto | `User-Agent` header value | - -`persistent` is an alias for `keep_alive` for compatibility with Cube internals. - -## Keep-Alive Mode - -Requires `faraday-net_http_persistent` and `connection_pool` in your Gemfile. +### Restarting a compute session ```ruby -client = OctaSpace::Client.new( - api_key: ENV["OCTA_API_KEY"], - keep_alive: true, - pool_size: ENV.fetch("RAILS_MAX_THREADS", 5).to_i, - idle_timeout: 120 -) - -# Diagnostics -client.transport_stats -# => { mode: :persistent, pools: { "https://api.octa.space" => { size: 5, available: 4 } } } - -# Explicit shutdown (optional — Railtie does this automatically in Rails) -client.shutdown +client.compute.restart(uuid) # same parameters as before +client.compute.restart(uuid, disk_size: 20, envs: {"FOO" => "baz"}, http_ports: [8888], ssh: true) ``` -## URL Rotation / Failover - -```ruby -client = OctaSpace::Client.new( - api_key: ENV["OCTA_API_KEY"], - base_urls: ["https://api.octa.space", "https://api2.octa.space"] -) -``` +⚠️ With parameters, a restart **replaces all of them**. Every omitted parameter gets a fixed default, +not its current value: `disk_size` becomes 1 GB, `entrypoint`/`args` come from the image, and +`envs`, ports and `startup_script` are dropped. The API does not report a session's launch parameters, +so pass the full set you want. A session can be restarted once per 5 minutes and 6 times per hour +(`OctaSpace::RateLimitError` with `retry_after`); while it is still starting or restarting the API +answers `OctaSpace::ConflictError`. -- Requests are distributed round-robin across healthy endpoints. -- If an endpoint raises a connection or timeout error, it enters a **30-second cooldown** and traffic shifts to the remaining endpoints. -- After cooldown, the endpoint is re-admitted automatically. +### Sessions and SSH -## Hooks +The API reports running sessions only. Once a session stops, `get`, `logs` and `stop` raise +`OctaSpace::NotFoundError`; there is no session history. `stop` is asynchronous: wait for the 404 with +`wait_stopped`. A session stopped before its `min_rent_minutes` is charged for that long. -```ruby -OctaSpace.configure do |config| - config.on_request = ->(req) { Rails.logger.debug "→ #{req[:method].upcase} #{req[:path]}" } - config.on_response = ->(resp) { Rails.logger.debug "← #{resp.status} (#{resp.request_id})" } -end -``` +`ssh.web` (web terminal) contains credentials, and `fm` (file manager) needs no login: treat both as +secrets. `inspect` redacts them. `ssh.*.user` comes from the image, so pass it to `ssh` as a separate +argument after `--`: `ssh -p PORT -l USER -- HOST`. -`on_request` receives `{ method:, path:, params: }`. -`on_response` receives an `OctaSpace::Response` instance. +## Errors -## Error Handling +``` +OctaSpace::Error +├── ConfigurationError +├── NetworkError +│ ├── ConnectionError +│ └── TimeoutError +│ └── WaitTimeoutError wait_ready / wait_stopped ran out of time +└── ApiError status, message, body, retry_after, rate_limit, request_id + ├── ValidationError 400, 422 + ├── AuthenticationError 401, or no key configured + ├── PermissionError 403 + ├── NotFoundError 404 (a session that stopped) + │ └── SessionStoppedError the session stopped during wait_ready + ├── ConflictError 409 + ├── RateLimitError 429 + └── ServerError 5xx + ├── BadGatewayError 502 + ├── ServiceUnavailableError 503 (e.g. render temporarily unavailable) + └── GatewayTimeoutError 504 +``` ```ruby begin - client.nodes.find(999_999) -rescue OctaSpace::NotFoundError => e - puts "Not found — request_id: #{e.request_id}" -rescue OctaSpace::AuthenticationError - puts "Invalid API key" + client.compute.restart(uuid) rescue OctaSpace::RateLimitError => e - sleep e.retry_after - retry -rescue OctaSpace::ConnectionError, OctaSpace::TimeoutError => e - puts "Network error: #{e.message}" -rescue OctaSpace::Error => e - puts "API error #{e.status}: #{e.message}" + puts "#{e.message} (wait #{e.retry_after} s)" +rescue OctaSpace::ConflictError => e + puts e.message # "Session is still starting or restarting" end ``` -### Error hierarchy +### Retries and rate limits -``` -OctaSpace::Error -├── ConfigurationError — missing gems, invalid config -├── NetworkError — no HTTP response received -│ ├── ConnectionError — TCP connection refused / failed -│ └── TimeoutError — open/read timeout -└── ApiError — HTTP response received with error status - ├── AuthenticationError 401 - ├── PermissionError 403 - ├── NotFoundError 404 - ├── ValidationError 422 - ├── RateLimitError 429 → #retry_after (seconds) - └── ServerError 5xx - ├── BadGatewayError 502 - ├── ServiceUnavailableError 503 - └── GatewayTimeoutError 504 -``` +- GET requests and `sessions.stop` are retried on network errors and on 429/502/503/504, with + exponential backoff and jitter, honouring `Retry-After` up to `max_retry_after` (60 s). A longer wait + (such as the restart cooldown) is raised to the caller instead. +- `compute.start`, `compute.restart`, `render.start` and the batches are **never** retried: they cost + money and have their own limit (10 a minute). +- With several `base_urls`, GET requests fail over to the next URL on network errors. Requests that + change state go to one URL only. +- Limits: 60 requests a minute per IP without a key, 300 per account with one. + `client.rate_limit` is the `RateLimit-*` state of the latest response. -All `ApiError` subclasses expose: +## Configuration -- `#status` — HTTP status code -- `#request_id` — value of `X-Request-Id` response header -- `#response` — the raw `OctaSpace::Response` object +```ruby +OctaSpace.configure do |config| + config.api_key = ENV["OCTA_API_KEY"] + config.base_url = ENV.fetch("OCTA_API_URL", OctaSpace::DEFAULT_BASE_URL) +end -## Types (Value Objects) +client = OctaSpace.client # shared client built from the global configuration +other = OctaSpace::Client.new(api_key: "octa_ak_…", read_timeout: 60) +``` -The `OctaSpace::Types` namespace provides immutable `Data.define` value objects for domain entities. They are **not** returned by default — resources return raw `response.data` (Hash/Array). Use them explicitly when you want structured objects: +| Option | Default | Description | +|---|---|---| +| `api_key` | `nil` | `octa_ak_…` key; a pasted `Bearer …` value is accepted | +| `base_url` | `https://api-dev.octa.space` | API endpoint | +| `base_urls` | `nil` | Ordered endpoints; GET requests fail over between them | +| `open_timeout` / `read_timeout` / `write_timeout` | `10` / `30` / `30` | Seconds | +| `max_retries` | `2` | Retries of GET and idempotent requests | +| `retry_interval` / `backoff_factor` / `max_retry_interval` | `0.5` / `2.0` / `10.0` | Backoff, seconds | +| `max_retry_after` | `60` | Longest `Retry-After` waited for automatically | +| `keep_alive` | `false` | Persistent connections + pool (needs the optional gems) | +| `pool_size` / `pool_timeout` / `idle_timeout` | `5` / `5` / `60` | Keep-alive pool | +| `ssl_verify` | `true` | Verify TLS certificates | +| `on_request` | `nil` | `callable(hash)` before each attempt: `:method, :url, :path, :params, :attempt` | +| `on_response` | `nil` | `callable(OctaSpace::Response)` after each response | +| `logger` / `log_level` | `nil` / `:info` | One line per request; headers and bodies are never logged | +| `user_agent` | `octaspace-ruby/ Ruby/` | `User-Agent` header | + +### Rails ```ruby -response = client.nodes.find(123) -node = OctaSpace::Types::Node.new(**response.data.transform_keys(&:to_sym)) - -node.online? # => true / false -node.state # => "online" -node.id # => 123 +# config/initializers/octaspace.rb +OctaSpace.configure do |config| + config.api_key = Rails.application.credentials.dig(:octaspace, :api_key) + config.keep_alive = true + config.pool_size = ENV.fetch("RAILS_MAX_THREADS", 5).to_i + config.logger = Rails.logger +end ``` -Available types: `Node`, `Account`, `Balance`, `Session`. +`OctaSpace.client` returns a shared client; the Railtie shuts its connection pools down when the +process exits. ## Development -### Gem Development - ```bash -bin/console # IRB with gem loaded -bundle exec standardrb # lint (StandardRB) -bundle exec rake test # tests only -``` - -### Local Standalone Testing - -You can verify the gem in a clean Ruby environment without Rails: - -1. Build the gem: `gem build octaspace.gemspec` -2. Install it locally: `gem install ./octaspace-0.2.0.gem` -3. Test in IRB: - -```ruby -irb -> require 'octaspace' -> client = OctaSpace::Client.new # Test public endpoints -> client.network.info -> client_auth = OctaSpace::Client.new(api_key: "token") # Test authenticated client -> client_auth.accounts.profile.data # Retrieving profile data (email, ID, etc.) +bundle install +bundle exec rake test # unit tests (WebMock, no network) +bundle exec standardrb # lint +bundle exec bundle-audit check --update +bin/console # IRB with the gem loaded +bin/smoke # live read-only checks against api-dev +OCTA_API_KEY=octa_ak_… bin/smoke --compute M95S-WZG8-7 # plus a paid start/stop round trip ``` -## Packaging and Publishing - -See [PUBLISHING.md](PUBLISHING.md) for instructions on how to package and release new versions of the gem. +Work on the new API happens on the `next` branch: see [docs/GIT_WORKFLOW_NEXT.md](docs/GIT_WORKFLOW_NEXT.md). +Releases: [PUBLISHING.md](PUBLISHING.md). ## License diff --git a/bin/console b/bin/console index db4dbb8..a442783 100755 --- a/bin/console +++ b/bin/console @@ -9,7 +9,7 @@ require "octaspace" # # Example: # client = OctaSpace::Client.new(api_key: ENV["OCTA_API_KEY"]) -# client.nodes.list +# client.compute.list require "irb" IRB.start(__FILE__) diff --git a/bin/smoke b/bin/smoke new file mode 100755 index 0000000..eeb0a15 --- /dev/null +++ b/bin/smoke @@ -0,0 +1,75 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Live smoke test against the next API. +# +# bin/smoke # public endpoints only +# OCTA_API_KEY=octa_ak_… bin/smoke # plus the read-only authenticated ones +# OCTA_API_KEY=octa_ak_… bin/smoke --compute M95S-WZG8-7 +# # plus a paid round trip on that node: start a tiny alpine session, +# # wait until ready, read its logs, stop it and wait until it is gone +# +# OCTA_API_URL overrides the base URL (default: https://api-dev.octa.space). +# Exits with status 1 when any check fails. + +$LOAD_PATH.unshift File.expand_path("../lib", __dir__) +require "octaspace" + +compute_node = (i = ARGV.index("--compute")) && ARGV[i + 1] +abort "usage: bin/smoke [--compute NODE_NUMBER]" if ARGV.include?("--compute") && compute_node.nil? + +options = {} +options[:base_url] = ENV["OCTA_API_URL"] if ENV["OCTA_API_URL"].to_s.strip != "" +client = OctaSpace::Client.new(api_key: ENV["OCTA_API_KEY"], **options) +authenticated = !ENV["OCTA_API_KEY"].to_s.strip.empty? +failures = 0 + +check = lambda do |name, &block| + detail = block.call + puts "ok #{name}#{" — #{detail}" if detail}" +rescue OctaSpace::Error => e + failures += 1 + puts "FAIL #{name} — #{e.class}: #{e.message}" +end + +puts "OctaSpace Ruby SDK #{OctaSpace::VERSION} → #{options[:base_url] || OctaSpace::DEFAULT_BASE_URL}" + +check.call("network.info") { "#{client.network.info.nodes.online} nodes online" } +check.call("stats.gpu_current") { "#{client.stats.gpu_current.gpus.size} GPU models" } +check.call("stats.gpu_history") { "#{client.stats.gpu_history.size} models" } +check.call("stats.gpu_locations") { "#{client.stats.gpu_locations.size} locations" } +check.call("compute.list") { "#{client.compute.list.size} machines" } +check.call("render.list") { "#{client.render.list.size} nodes" } + +if authenticated + check.call("accounts.balance") { client.accounts.balance.formatted } + check.call("sessions.list") { "#{client.sessions.list.size} running" } + check.call("hosting.nodes") { "#{client.hosting.nodes.size} online nodes" } +else + puts "skip authenticated checks (OCTA_API_KEY is not set)" +end + +if compute_node + abort "--compute needs OCTA_API_KEY" unless authenticated + + uuid = nil + begin + check.call("compute.start") do + uuid = client.compute.start(node_number: compute_node, image: "alpine:3.20", disk_size: 1, + entrypoint: "sh -c", args: "\"echo octaspace-ruby-smoke; sleep 600\"") + end + if uuid + check.call("sessions.wait_ready") { client.sessions.wait_ready(uuid, timeout: 300).status } + check.call("sessions.logs") { "#{client.sessions.logs(uuid, tail: 10).system.size} system events" } + end + ensure + # Always stop the session this script started, and only that one. + if uuid + check.call("sessions.stop") { client.sessions.stop(uuid) } + check.call("sessions.wait_stopped") { client.sessions.wait_stopped(uuid, timeout: 120) && "gone" } + end + end +end + +puts(failures.zero? ? "all checks passed" : "#{failures} check(s) failed") +exit(failures.zero? ? 0 : 1) diff --git a/docs/migration-next.md b/docs/migration-next.md new file mode 100644 index 0000000..d581640 --- /dev/null +++ b/docs/migration-next.md @@ -0,0 +1,75 @@ +# Migrating from 0.2.x (legacy API) to 0.3 (next API) + +0.3 targets the next-generation OctaSpace API and does not work with the legacy one: the two APIs +differ in authentication, identifiers, money units and most payloads. Keep 0.2.x for code that still +talks to the legacy production hosts. + +## Client and authentication + +| 0.2.x | 0.3 | +|---|---| +| Default `base_url` `https://api.octa.space` | `https://api-dev.octa.space` (`OctaSpace::BASE_URL_DEV`), the test stand of the new API | +| `Authorization: ` | `Authorization: Bearer ` | +| Account token (64 hex characters) | API key `octa_ak_…` from Cube → Profile → Credentials; legacy tokens are refused | +| Resource methods return `OctaSpace::Response` | They return typed models (`Types::*`), a uuid, or `nil` | +| `response.data` | `model.to_h` (raw JSON) or `model[:field]` | + +## Resources + +| 0.2.x | 0.3 | +|---|---| +| `accounts.profile`, `accounts.generate_wallet` | removed (no public endpoint; `accounts.balance` is the way to check a key) | +| `accounts.balance` → `{balance}` in Wei | `accounts.balance` → `Types::Balance` (`balance` integer micro-USD, `currency`, `decimals`) | +| `nodes.list`, `nodes.find(id)` | `hosting.nodes` (online nodes of the account only, no per-node endpoint) | +| `nodes.update_prices(id, …)` | `hosting.set_prices(number, base:, storage:, traffic:, min_rent_minutes:)` (integer micro-USD) | +| `nodes.download_ident`, `download_logs`, `reboot` | removed | +| `services.mr.list` | `compute.list` | +| `services.mr.create(node_id:, …)` | `compute.start(node_number:, image:, …)` → uuid; `compute.start_batch(items)` | +| `start_command` | `entrypoint` + `args` | +| — | `compute.restart(uuid, **params)`; `ssh:`, `fm:`, `startup_script:` on start | +| `services.render.list` / `create(node_id:, disk_size:)` | `render.list` / `render.start(node_number:)`, `render.start_batch` | +| `services.vpn.*` | removed (no VPN in the public API yet) | +| `services.session(uuid).info` | `sessions.get(uuid)` | +| `services.session(uuid).logs(recent:)` | `sessions.logs(uuid, tail:)` (running sessions only) | +| `services.session(uuid).stop(score:)` | `sessions.stop(uuid)` → 202; `sessions.wait_stopped(uuid)` | +| `sessions.list(recent: true)` | `sessions.list` (running sessions only; no history) | +| `apps.list` | removed | +| `idle_jobs.*` | removed | +| `network.info` (legacy shape) | `network.info` → `Types::Network` (new shape) | +| — | `stats.gpu_current`, `stats.gpu_history`, `stats.gpu_locations` | + +## Data + +- **Node identifier.** The integer `node_id` is gone everywhere. Nodes are identified by + `node_number`, a string like `M95S-WZG8-7` (the API ignores case, dashes and spaces). The API has + no mapping from old ids. +- **Money.** Wei, ETH and USD floats are replaced by integer micro-USD (1 = $0.000001) in every price, + charge and balance. Use `OctaSpace::Money` to convert and format. `*_usd`, `*_ether`, `total_price_*` + and `market_price` are gone. +- **Session state.** `is_ready` is replaced by `status` (`"starting"` / `"ready"`, `ready?`), with + `progress` and `pull_percent` while starting. `ssh_direct`/`ssh_proxy`/`ssh_web` are grouped in + `ssh` (`direct`, `proxy`, `web`); `user_envs` is `envs`; `ports_matrix` is `ports`. +- **Bandwidth.** `net_down_mbps` / `net_up_mbps` are integer Mbit/s; the old `net_*_mbits` + (bytes per second) and `PayloadHelpers.normalize_marketplace_bandwidth` are gone. +- **Time.** `started_at` and log `ts` are Unix milliseconds, `at` is Unix seconds, `online_at` is + ISO 8601; the models offer `started_time`, `time` and `online_time`. + +## Errors + +- 400 is now `OctaSpace::ValidationError` (previously a plain `ApiError`), 409 is the new + `OctaSpace::ConflictError`. +- `OctaSpace::ProvisionRejectedError` is gone: a single `compute.start` raises + `ValidationError` with the API's reason, and `start_batch` reports failures per item. +- `ApiError#retry_after` and `#rate_limit` are available on every API error; `client.rate_limit` + gives the latest `RateLimit-*` state. + +## Behaviour + +- Only GET requests and `sessions.stop` are retried. Starting or restarting a service is never + retried, and a request that changes state is never sent to a second base URL. +- Methods that need a key raise `AuthenticationError` before the request when none is configured. +- `on_request` no longer receives request headers, and the logger no longer prints them. +- Ruby 3.3 or newer is required; the playground app, `MockTransport` and the Rails Engine are gone. + +Background, field by field: the shared API difference document used for all OctaSpace SDKs +(go-sdk `.tmp/Next/API-NEXT-DIFF.md`). diff --git a/lib/octaspace.rb b/lib/octaspace.rb index 490d771..1b72564 100644 --- a/lib/octaspace.rb +++ b/lib/octaspace.rb @@ -28,7 +28,7 @@ # OctaSpace Ruby SDK # -# Official Ruby client for the OctaSpace API. +# Official Ruby client for the next-generation OctaSpace API. # Supports standard and keep-alive (persistent) connection modes, # automatic URL rotation/failover, retry with exponential backoff, # and optional Rails integration. @@ -39,10 +39,10 @@ # end # # client = OctaSpace.client -# client.nodes.list +# client.compute.list # client.accounts.balance # -# @see https://api.octa.space/api-docs +# @see https://api-dev.octa.space/swagger module OctaSpace class << self # Global configuration object diff --git a/lib/octaspace/client.rb b/lib/octaspace/client.rb index 07bd046..a0ee11e 100644 --- a/lib/octaspace/client.rb +++ b/lib/octaspace/client.rb @@ -8,7 +8,7 @@ module OctaSpace # # @example Standard mode (default) # client = OctaSpace::Client.new(api_key: ENV["OCTA_API_KEY"]) - # client.nodes.list + # client.compute.list # client.accounts.balance # # @example Keep-alive mode (persistent connections + pool) @@ -21,7 +21,7 @@ module OctaSpace # @example Multiple API endpoints with failover # client = OctaSpace::Client.new( # api_key: ENV["OCTA_API_KEY"], - # base_urls: ["https://api.octa.space", "https://api2.octa.space"] + # base_urls: ["https://api-dev.octa.space", "https://mirror.example"] # ) # # @example Without API key (public endpoints only) diff --git a/lib/octaspace/middleware/url_rotator.rb b/lib/octaspace/middleware/url_rotator.rb index 8a59802..7881691 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.octa.space", - # "https://api2.octa.space" + # "https://api-dev.octa.space", + # "https://mirror.example" # ]) # # url = rotator.next_url # rotator.mark_failed(url) - # rotator.stats # => { total: 2, available: 1, failed: ["https://api.octa.space"] } + # rotator.stats # => { total: 2, available: 1, failed: ["https://api-dev.octa.space"] } class UrlRotator # Seconds a failed URL is excluded from rotation FAILURE_COOLDOWN = 30 diff --git a/lib/octaspace/version.rb b/lib/octaspace/version.rb index 475e29d..4e2b7c1 100644 --- a/lib/octaspace/version.rb +++ b/lib/octaspace/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module OctaSpace - VERSION = "0.2.0" + VERSION = "0.3.0" end From 1ccca29fea7c3a7cc6a9d264a05080f994f3b188 Mon Sep 17 00:00:00 2001 From: secretpray Date: Tue, 3 Nov 2026 19:58:15 +0200 Subject: [PATCH 8/9] docs: keep only what SDK users need The repository is public; its documentation is for people who use the gem, not for its development process. - remove docs/GIT_WORKFLOW_NEXT.md, docs/migration-next.md, PUBLISHING.md, bin/smoke and the CI smoke job - README: drop the branch/test-stand banner, the "next-generation" wording, installation from a branch, the development section and the feature list; use placeholder node numbers - CHANGELOG 0.3.0: user-facing changes only - code comments and the 401 hint no longer talk about "next" or "legacy" APIs; drop BASE_URL_DEV and the persistent alias - test fixtures move out of test/fixtures/next - dependabot: drop the comment about branch plans --- .github/dependabot.yml | 2 - .github/workflows/ci.yml | 23 -- CHANGELOG.md | 38 ++-- PUBLISHING.md | 85 ------- README.md | 214 +++++------------- bin/smoke | 75 ------ docs/GIT_WORKFLOW_NEXT.md | 106 --------- docs/migration-next.md | 75 ------ lib/octaspace.rb | 2 +- lib/octaspace/configuration.rb | 13 +- lib/octaspace/errors.rb | 8 +- lib/octaspace/json_null.rb | 2 +- lib/octaspace/model.rb | 2 +- lib/octaspace/resources/compute.rb | 2 +- lib/octaspace/resources/hosting.rb | 2 +- lib/octaspace/resources/sessions.rb | 2 +- lib/octaspace/types/hosting.rb | 2 +- lib/octaspace/types/machines.rb | 2 +- test/fixtures/{next => }/account_balance.json | 0 test/fixtures/{next => }/batch.json | 0 test/fixtures/{next => }/hosting_nodes.json | 0 test/fixtures/{next => }/network.json | 0 .../fixtures/{next => }/services_compute.json | 0 test/fixtures/{next => }/services_render.json | 0 .../{next => }/session_compute_net_mbps.json | 0 .../{next => }/session_compute_ready.json | 0 .../{next => }/session_compute_ssh_proxy.json | 0 .../{next => }/session_compute_starting.json | 0 test/fixtures/{next => }/session_logs.json | 0 .../{next => }/stats_gpu_current.json | 0 .../{next => }/stats_gpu_history.json | 0 .../{next => }/stats_gpu_locations.json | 0 test/octaspace/configuration_test.rb | 7 - test/octaspace/resources/accounts_test.rb | 2 +- test/octaspace/resources/compute_test.rb | 6 +- test/octaspace/resources/hosting_test.rb | 2 +- test/octaspace/resources/network_test.rb | 4 +- test/octaspace/resources/render_test.rb | 4 +- test/octaspace/resources/sessions_test.rb | 22 +- test/octaspace/resources/stats_test.rb | 6 +- .../transport/faraday_transport_test.rb | 4 +- 41 files changed, 118 insertions(+), 594 deletions(-) delete mode 100644 PUBLISHING.md delete mode 100755 bin/smoke delete mode 100644 docs/GIT_WORKFLOW_NEXT.md delete mode 100644 docs/migration-next.md rename test/fixtures/{next => }/account_balance.json (100%) rename test/fixtures/{next => }/batch.json (100%) rename test/fixtures/{next => }/hosting_nodes.json (100%) rename test/fixtures/{next => }/network.json (100%) rename test/fixtures/{next => }/services_compute.json (100%) rename test/fixtures/{next => }/services_render.json (100%) rename test/fixtures/{next => }/session_compute_net_mbps.json (100%) rename test/fixtures/{next => }/session_compute_ready.json (100%) rename test/fixtures/{next => }/session_compute_ssh_proxy.json (100%) rename test/fixtures/{next => }/session_compute_starting.json (100%) rename test/fixtures/{next => }/session_logs.json (100%) rename test/fixtures/{next => }/stats_gpu_current.json (100%) rename test/fixtures/{next => }/stats_gpu_history.json (100%) rename test/fixtures/{next => }/stats_gpu_locations.json (100%) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c3338ba..568e7ed 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,7 +1,5 @@ version: 2 -# Development happens on `next` until the new API is released; switch -# target-branch back to `main` when `next` is merged. updates: - package-ecosystem: bundler directory: / diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 849e145..cec9671 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,26 +81,3 @@ jobs: - name: bundler-audit run: bundle exec bundle-audit check --update - smoke: - name: Smoke test (live API) - runs-on: ubuntu-latest - timeout-minutes: 10 - # Only after a merge: pull requests from forks must not reach the live API with the secret. - if: github.event_name == 'push' - needs: test - - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - uses: ruby/setup-ruby@de492cf36cbab201753d720ce4f4e336cd688c94 # v1.336.0 - with: - ruby-version: "4.0" - bundler-cache: true - - # Read-only. Without the OCTA_API_KEY secret only the public checks run. - - name: Smoke - env: - OCTA_API_KEY: ${{ secrets.OCTA_API_KEY }} - run: bin/smoke diff --git a/CHANGELOG.md b/CHANGELOG.md index 696fa9f..9bccdfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.3.0] - Unreleased -Moves the SDK to the next-generation OctaSpace API (`https://api-dev.octa.space`). This release is -not compatible with the legacy API; see [docs/migration-next.md](docs/migration-next.md). +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. ### Added @@ -16,31 +16,29 @@ not compatible with the legacy API; see [docs/migration-next.md](docs/migration- - `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` (with `GpuHistory#points`), `gpu_locations`. -- Typed read-only models (`OctaSpace::Model`, `OctaSpace::Types::*`) that keep unknown fields. -- `OctaSpace::Money` for integer micro-USD. -- `OctaSpace::ConflictError` (409), `SessionStoppedError`, `WaitTimeoutError`. -- `OctaSpace::RateLimit`, `client.rate_limit`, `ApiError#retry_after` / `#rate_limit` / `#body`. -- `bin/smoke` live checks and a CI smoke job; `bundler-audit` in CI; Dependabot. +- `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`. ### Changed -- Default base URL is `https://api-dev.octa.space`; authentication is `Authorization: Bearer ` - with `octa_ak_…` keys. -- Resource methods return models, uuids or `nil` instead of `OctaSpace::Response`. -- 400 maps to `ValidationError`; error messages come from the API's `{"message"}`. -- Retries only for GET and the idempotent session stop, honouring `Retry-After` up to 60 s; - failover across `base_urls` only for GET. -- Logging never includes headers or bodies; `on_request` no longer receives headers. +- 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. ### Removed -- Legacy resources: `nodes`, `apps`, `idle_jobs`, `services` (`mr`, `vpn`, `render`, `session`), - `accounts.profile`, `accounts.generate_wallet`. -- `ProvisionRejectedError`, `PayloadHelpers`, the old `Types::Account/Balance/Node/Session`. -- The Rails playground (`bin/playground`, `test/dummy`), `MockTransport`, the Rails Engine and the - Appraisal matrix. +- `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. ## [0.2.0] - 2026-04-16 diff --git a/PUBLISHING.md b/PUBLISHING.md deleted file mode 100644 index dc3fd7b..0000000 --- a/PUBLISHING.md +++ /dev/null @@ -1,85 +0,0 @@ -# Packaging and Publishing Guide - -How to validate, build, and publish the `octaspace` gem. - -Important release policy for this repository: - -- there is no GitHub Actions workflow that publishes to RubyGems automatically -- publish only after the feature PR is reviewed, polished, and merged into `main` -- publish from a clean local checkout of `main` -- do not use `rake release` in this repository; build, push, tag, and release explicitly as separate manual steps - -## 0. Release Sequence - -1. Finish the feature branch work and merge the PR into `main`. -2. Switch to `main` locally and pull the merged commit. -3. Update `CHANGELOG.md` and bump `lib/octaspace/version.rb` if that was not already done in the merged branch. -4. Run verification locally. -5. Build the gem. -6. Push the gem to RubyGems manually. -7. Tag the exact published commit and push the tag. -8. Create the GitHub Release manually from that tag. - -## 1. Pre-release Checklist - -```bash -git switch main -git pull --ff-only -bundle exec rake test # all tests pass -bundle exec standardrb # zero linting violations -gem build octaspace.gemspec # builds without warnings -``` - -## 2. Local Verification - -Install the built gem outside the project and confirm it loads correctly: - -```bash -gem install ./octaspace-0.3.0.gem -ruby -e "require 'octaspace'; p OctaSpace::Client.new" -``` - -Or interactively: - -```bash -irb -> require 'octaspace' -> OctaSpace::Client.new # public endpoints (no key) -> OctaSpace::Client.new(api_key: "token") # authenticated client -``` - -## 3. Pre-publish Dry Run - -There is no public staging server for RubyGems (test.rubygems.org was shut down). -Use these alternatives to verify the gem before publishing: - -- **Inspect gem contents:** - ```bash - gem unpack octaspace-0.3.0.gem --target=/tmp/octaspace-check - find /tmp/octaspace-check -type f | sort - rm -rf /tmp/octaspace-check - ``` -- **Check metadata that RubyGems.org will display:** - ```bash - gem specification octaspace-0.3.0.gem - ``` -- **Install and test in a clean environment:** - ```bash - gem install ./octaspace-0.3.0.gem - ruby -e "require 'octaspace'; p OctaSpace::Client.new; puts OctaSpace::VERSION" - ``` - -## 4. Publish to RubyGems.org - -```bash -gem push octaspace-0.3.0.gem -``` - -You will be prompted for credentials on first push. The API key is saved to `~/.gem/credentials`. - -## 5. Post-publish - -- Verify the gem page at [rubygems.org/gems/octaspace](https://rubygems.org/gems/octaspace). -- Tag the exact published commit: `git tag v0.3.0 && git push origin v0.3.0`. -- Create the GitHub Release manually from tag `v0.3.0`. -- Bump `lib/octaspace/version.rb` for the next development cycle in a follow-up commit. diff --git a/README.md b/README.md index cf25435..931387c 100644 --- a/README.md +++ b/README.md @@ -4,47 +4,24 @@ [![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) -Official Ruby SDK for the next-generation OctaSpace API. - -> **The `next` branch targets the new API**, currently served only by the test stand -> `https://api-dev.octa.space` ([Swagger](https://api-dev.octa.space/swagger)). The production hosts -> (`api.octa.computer`, `api.octa.space`) still serve the legacy API, which the 0.2.x gems support. -> Moving from 0.2.x: see [docs/migration-next.md](docs/migration-next.md). - -## Features - -- **Typed resources** for every endpoint of the API: compute, render, sessions, hosting, account, network, GPU statistics -- **Read-only models** that tolerate new API fields: `machine.node_number`, `machine[:new_field]`, `machine.to_h` -- **Safe retries**: only reads and the idempotent session stop are retried, honouring `Retry-After`; starts that cost money never are -- **Typed errors** for 400/401/403/404/409/429/5xx with `retry_after` and `rate_limit` -- **Rate limit state** from `RateLimit-*` headers: `client.rate_limit` -- **Session helpers**: `wait_ready` with progress, `wait_stopped` -- **Exact money helpers** for integer micro-USD: `OctaSpace::Money` -- **Keep-alive mode** (persistent connections + pool) and **failover** across several base URLs for reads -- **No secrets in logs**: headers are never logged, access URLs are redacted in `inspect` -- Ruby ≥ 3.3; optional Railtie for Rails apps +Official Ruby SDK for the [OctaSpace API](https://api-dev.octa.space/swagger). Requires Ruby 3.3 or newer. ## Installation ```ruby # Gemfile -gem "octaspace", github: "octaspace/ruby-sdk", branch: "next" - -# Optional — required for keep_alive: true -gem "faraday-net_http_persistent" -gem "connection_pool" +gem "octaspace" ``` ## API key -Create a key in Cube under **Profile → Credentials**. Keys look like `octa_ak_…` and are sent as -`Authorization: Bearer `. Legacy account tokens are refused by the new API (the SDK says so in -the error message). +Create a key in Cube under **Profile → Credentials**. Keys start with `octa_ak_` and are sent as +`Authorization: Bearer `. -The key is optional: network and GPU statistics never use it, and machine listings work without it -(but the anonymous listing is cached for up to an hour; with a key it is at most 10 seconds old). -Methods that need a key raise `OctaSpace::AuthenticationError` before sending anything when none is -configured. +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. ## Quick start @@ -53,11 +30,11 @@ require "octaspace" client = OctaSpace::Client.new(api_key: ENV["OCTA_API_KEY"]) -client.accounts.balance.formatted # => "$2.147661" +client.accounts.balance.formatted # => "$12.500000" machine = client.compute.list.min_by(&:base_price) uuid = client.compute.start( - node_number: machine.node_number, # e.g. "M95S-WZG8-7" + node_number: machine.node_number, image: "ubuntu:24.04", disk_size: 20, http_ports: [8888], @@ -69,7 +46,7 @@ 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 access, or the SSH proxy +session.ssh.endpoint # direct SSH, or the SSH proxy client.sessions.logs(uuid, tail: 100).container client.sessions.stop(uuid) @@ -86,95 +63,79 @@ client.sessions.wait_stopped(uuid) | `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` (object) | yes | session uuid | -| `compute.start_batch(items)` | `POST /services/compute` (array) | yes | `[Types::BatchResult]` | +| `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` (object) | yes | session uuid | -| `render.start_batch(items)` | `POST /services/render` (array) | yes | `[Types::BatchResult]` | +| `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 `get` | yes | `Types::Session` | -| `sessions.wait_stopped(uuid, timeout:, interval:)` | polls `get` until 404 | yes | `true` | +| `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` | -### Models - -Every response is wrapped in a read-only model with readers for the fields of the API spec. Fields -the SDK does not know yet are kept: read them with `model[:field]`, and get the parsed JSON with -`model.to_h`. Models support pattern matching: - -```ruby -case client.sessions.get(uuid) -in OctaSpace::Types::ComputeSession => s if s.ready? - puts s.public_ip -in {status: "starting", progress:} - puts progress -end -``` +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. -`sessions.list` and `sessions.get` return a `ComputeSession` or a `RenderSession` by the `service` -field, and a plain `Types::Session` for a service the SDK does not know yet. - -### Units +## 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), `activity.started_at` | Unix seconds | `time`, `started_time` | -| `online_at` (hosting) | ISO 8601 string | `online_time` | -| `net_down_mbps`, `net_up_mbps` | Mbit/s (0 in listings = not measured) | — | -| `memory`, `disk` in listings | bytes (free) | — | -| `node_hw.disk` in sessions | GB (rented) | — | +| `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 | | ```ruby OctaSpace::Money.format(100) # => "$0.000100" -OctaSpace::Money.to_usd(1_250_000) # => (5/4) OctaSpace::Money.from_usd("0.25") # => 250000 -client.hosting.set_prices("M95S-WZG8-7", base: OctaSpace::Money.from_usd("0.25"), min_rent_minutes: 15) +client.hosting.set_prices("7KQ4-M2XD-4", base: OctaSpace::Money.from_usd("0.25"), min_rent_minutes: 15) ``` -### Batch starts +## Starting several sessions ```ruby results = client.compute.start_batch([ - {id: "a", node_number: "M95S-WZG8-7", image: "ubuntu:24.04"}, - {id: "b", node_number: "7R2S-S05E-S", image: "ubuntu:24.04"} + {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}" } ``` -Each item is validated on its own; a failed item is reported in its result, not raised. Items without -an `id` get their position in the array. A single `start` raises instead -(`OctaSpace::ValidationError` with the API's reason). +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. -### Restarting a compute session +## Restarting a compute session ```ruby client.compute.restart(uuid) # same parameters as before client.compute.restart(uuid, disk_size: 20, envs: {"FOO" => "baz"}, http_ports: [8888], ssh: true) ``` -⚠️ With parameters, a restart **replaces all of them**. Every omitted parameter gets a fixed default, -not its current value: `disk_size` becomes 1 GB, `entrypoint`/`args` come from the image, and -`envs`, ports and `startup_script` are dropped. The API does not report a session's launch parameters, -so pass the full set you want. A session can be restarted once per 5 minutes and 6 times per hour -(`OctaSpace::RateLimitError` with `retry_after`); while it is still starting or restarting the API +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`. -### Sessions and SSH +## Sessions and SSH -The API reports running sessions only. Once a session stops, `get`, `logs` and `stop` raise -`OctaSpace::NotFoundError`; there is no session history. `stop` is asynchronous: wait for the 404 with -`wait_stopped`. A session stopped before its `min_rent_minutes` is charged for that long. +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. -`ssh.web` (web terminal) contains credentials, and `fm` (file manager) needs no login: treat both as -secrets. `inspect` redacts them. `ssh.*.user` comes from the image, so pass it to `ssh` as a separate -argument after `--`: `ssh -p PORT -l USER -- HOST`. +`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`. ## Errors @@ -184,102 +145,51 @@ OctaSpace::Error ├── NetworkError │ ├── ConnectionError │ └── TimeoutError -│ └── WaitTimeoutError wait_ready / wait_stopped ran out of time +│ └── WaitTimeoutError wait_ready / wait_stopped timed out └── ApiError status, message, body, retry_after, rate_limit, request_id ├── ValidationError 400, 422 - ├── AuthenticationError 401, or no key configured + ├── AuthenticationError 401, or no API key configured ├── PermissionError 403 - ├── NotFoundError 404 (a session that stopped) + ├── NotFoundError 404 │ └── SessionStoppedError the session stopped during wait_ready ├── ConflictError 409 ├── RateLimitError 429 └── ServerError 5xx ├── BadGatewayError 502 - ├── ServiceUnavailableError 503 (e.g. render temporarily unavailable) + ├── ServiceUnavailableError 503 └── GatewayTimeoutError 504 ``` -```ruby -begin - client.compute.restart(uuid) -rescue OctaSpace::RateLimitError => e - puts "#{e.message} (wait #{e.retry_after} s)" -rescue OctaSpace::ConflictError => e - puts e.message # "Session is still starting or restarting" -end -``` - -### Retries and rate limits - -- GET requests and `sessions.stop` are retried on network errors and on 429/502/503/504, with - exponential backoff and jitter, honouring `Retry-After` up to `max_retry_after` (60 s). A longer wait - (such as the restart cooldown) is raised to the caller instead. -- `compute.start`, `compute.restart`, `render.start` and the batches are **never** retried: they cost - money and have their own limit (10 a minute). -- With several `base_urls`, GET requests fail over to the next URL on network errors. Requests that - change state go to one URL only. -- Limits: 60 requests a minute per IP without a key, 300 per account with one. - `client.rate_limit` is the `RateLimit-*` state of the latest response. +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. ## Configuration ```ruby OctaSpace.configure do |config| config.api_key = ENV["OCTA_API_KEY"] - config.base_url = ENV.fetch("OCTA_API_URL", OctaSpace::DEFAULT_BASE_URL) end -client = OctaSpace.client # shared client built from the global configuration +client = OctaSpace.client # shared client other = OctaSpace::Client.new(api_key: "octa_ak_…", read_timeout: 60) ``` | Option | Default | Description | |---|---|---| -| `api_key` | `nil` | `octa_ak_…` key; a pasted `Bearer …` value is accepted | +| `api_key` | `nil` | API key | | `base_url` | `https://api-dev.octa.space` | API endpoint | -| `base_urls` | `nil` | Ordered endpoints; GET requests fail over between them | +| `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 and idempotent requests | -| `retry_interval` / `backoff_factor` / `max_retry_interval` | `0.5` / `2.0` / `10.0` | Backoff, seconds | -| `max_retry_after` | `60` | Longest `Retry-After` waited for automatically | -| `keep_alive` | `false` | Persistent connections + pool (needs the optional gems) | -| `pool_size` / `pool_timeout` / `idle_timeout` | `5` / `5` / `60` | Keep-alive pool | -| `ssl_verify` | `true` | Verify TLS certificates | -| `on_request` | `nil` | `callable(hash)` before each attempt: `:method, :url, :path, :params, :attempt` | -| `on_response` | `nil` | `callable(OctaSpace::Response)` after each response | +| `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 | -| `user_agent` | `octaspace-ruby/ Ruby/` | `User-Agent` header | - -### Rails - -```ruby -# config/initializers/octaspace.rb -OctaSpace.configure do |config| - config.api_key = Rails.application.credentials.dig(:octaspace, :api_key) - config.keep_alive = true - config.pool_size = ENV.fetch("RAILS_MAX_THREADS", 5).to_i - config.logger = Rails.logger -end -``` - -`OctaSpace.client` returns a shared client; the Railtie shuts its connection pools down when the -process exits. - -## Development - -```bash -bundle install -bundle exec rake test # unit tests (WebMock, no network) -bundle exec standardrb # lint -bundle exec bundle-audit check --update -bin/console # IRB with the gem loaded -bin/smoke # live read-only checks against api-dev -OCTA_API_KEY=octa_ak_… bin/smoke --compute M95S-WZG8-7 # plus a paid start/stop round trip -``` -Work on the new API happens on the `next` branch: see [docs/GIT_WORKFLOW_NEXT.md](docs/GIT_WORKFLOW_NEXT.md). -Releases: [PUBLISHING.md](PUBLISHING.md). +In a Rails app, put the `configure` block in an initializer. The shared client's connections are +closed when the process exits. ## License -[MIT](MIT-LICENSE) © OctaSpace Team +[MIT](MIT-LICENSE) diff --git a/bin/smoke b/bin/smoke deleted file mode 100755 index eeb0a15..0000000 --- a/bin/smoke +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# Live smoke test against the next API. -# -# bin/smoke # public endpoints only -# OCTA_API_KEY=octa_ak_… bin/smoke # plus the read-only authenticated ones -# OCTA_API_KEY=octa_ak_… bin/smoke --compute M95S-WZG8-7 -# # plus a paid round trip on that node: start a tiny alpine session, -# # wait until ready, read its logs, stop it and wait until it is gone -# -# OCTA_API_URL overrides the base URL (default: https://api-dev.octa.space). -# Exits with status 1 when any check fails. - -$LOAD_PATH.unshift File.expand_path("../lib", __dir__) -require "octaspace" - -compute_node = (i = ARGV.index("--compute")) && ARGV[i + 1] -abort "usage: bin/smoke [--compute NODE_NUMBER]" if ARGV.include?("--compute") && compute_node.nil? - -options = {} -options[:base_url] = ENV["OCTA_API_URL"] if ENV["OCTA_API_URL"].to_s.strip != "" -client = OctaSpace::Client.new(api_key: ENV["OCTA_API_KEY"], **options) -authenticated = !ENV["OCTA_API_KEY"].to_s.strip.empty? -failures = 0 - -check = lambda do |name, &block| - detail = block.call - puts "ok #{name}#{" — #{detail}" if detail}" -rescue OctaSpace::Error => e - failures += 1 - puts "FAIL #{name} — #{e.class}: #{e.message}" -end - -puts "OctaSpace Ruby SDK #{OctaSpace::VERSION} → #{options[:base_url] || OctaSpace::DEFAULT_BASE_URL}" - -check.call("network.info") { "#{client.network.info.nodes.online} nodes online" } -check.call("stats.gpu_current") { "#{client.stats.gpu_current.gpus.size} GPU models" } -check.call("stats.gpu_history") { "#{client.stats.gpu_history.size} models" } -check.call("stats.gpu_locations") { "#{client.stats.gpu_locations.size} locations" } -check.call("compute.list") { "#{client.compute.list.size} machines" } -check.call("render.list") { "#{client.render.list.size} nodes" } - -if authenticated - check.call("accounts.balance") { client.accounts.balance.formatted } - check.call("sessions.list") { "#{client.sessions.list.size} running" } - check.call("hosting.nodes") { "#{client.hosting.nodes.size} online nodes" } -else - puts "skip authenticated checks (OCTA_API_KEY is not set)" -end - -if compute_node - abort "--compute needs OCTA_API_KEY" unless authenticated - - uuid = nil - begin - check.call("compute.start") do - uuid = client.compute.start(node_number: compute_node, image: "alpine:3.20", disk_size: 1, - entrypoint: "sh -c", args: "\"echo octaspace-ruby-smoke; sleep 600\"") - end - if uuid - check.call("sessions.wait_ready") { client.sessions.wait_ready(uuid, timeout: 300).status } - check.call("sessions.logs") { "#{client.sessions.logs(uuid, tail: 10).system.size} system events" } - end - ensure - # Always stop the session this script started, and only that one. - if uuid - check.call("sessions.stop") { client.sessions.stop(uuid) } - check.call("sessions.wait_stopped") { client.sessions.wait_stopped(uuid, timeout: 120) && "gone" } - end - end -end - -puts(failures.zero? ? "all checks passed" : "#{failures} check(s) failed") -exit(failures.zero? ? 0 : 1) diff --git a/docs/GIT_WORKFLOW_NEXT.md b/docs/GIT_WORKFLOW_NEXT.md deleted file mode 100644 index 10d9513..0000000 --- a/docs/GIT_WORKFLOW_NEXT.md +++ /dev/null @@ -1,106 +0,0 @@ -# Working with the `next` branch - -`next` is the branch that moves the SDK to the new OctaSpace API (test stand `https://api-dev.octa.space`). -All changes for the new API go to `next` as pull requests; merging `next → main` is a separate decision, -made when the new API reaches production. `main` stays the branch of the current (legacy) SDK and its hotfixes. -Cube, go-sdk, js-sdk and octa-cli work the same way. - -The default branch on GitHub is **`main`**. A PR opened without an explicit base therefore targets `main`, -not `next`. - -## `main` and `next`, step by step - -| Step | Working with `main` | Working with `next` | -|---|---|---| -| Update branches | `git fetch origin` | `git fetch origin` (same) | -| Switch to the base branch | `git switch main` | `git switch next` | -| Pull the latest code | `git pull` | `git pull` (same; `next` tracks `origin/next`) | -| New task branch | `git switch -c feat/x` (from `main`) | `git switch -c feat/x origin/next` ← **the key difference** | -| Commit | `git add …` → `git commit` | same | -| First push of the branch | `git push -u origin feat/x` | same (push the task branch, not `next`) | -| Catch up with the base | `git rebase origin/main` | `git rebase origin/next` | -| PR with `gh` | `gh pr create` | `gh pr create --base next` ← **required** | -| PR on the website | base `main` is preselected | change **base: main → next** by hand | -| Branch diff against the base | `git diff main...HEAD` | `git diff origin/next...HEAD` | -| After the PR is merged | `git switch main && git pull` | `git switch next && git pull` | - -`git switch -c feat/x origin/next` creates the task branch straight from the remote `next`, -even if you are on `main` and have no local `next`. The branch tracks `origin/next` at first; -the first `git push -u origin feat/x` switches it to its own remote branch. - -## Typical task cycle - -```bash -git fetch origin -git switch -c feat/my-task origin/next # from a fresh next, even when you are on main -# ... changes ... -bundle exec rake test && bundle exec standardrb -git add -A && git commit -m "…" -git push -u origin feat/my-task -gh pr create --base next # without --base the PR opens against main -``` - -If something was merged into `next` while you were working, update the branch before the PR: - -```bash -git fetch origin -git rebase origin/next -git push --force-with-lease # a plain push is refused after a rebase -``` - -## Dependent PRs (a stack) - -When task B builds on task A that is not merged yet, create branch B from A and open the PR -with base A: `gh pr create --base feat/a`. The PR then shows only B's changes. - -⚠️ **Do not delete branch A when merging it** (`gh pr merge --delete-branch` or the -"Delete branch" button) while PR B points at it: GitHub **closes** PR B instead of retargeting it. -Merge the stack one PR at a time, in this order: - -```bash -gh pr merge --merge # without --delete-branch -gh pr edit --base next # retarget the next PR -git push origin --delete feat/a # now branch A can be deleted -``` - -If PR B was closed anyway: restore branch A at its last commit -(`git push origin :refs/heads/feat/a`), then `gh pr reopen ` and -`gh pr edit --base next`. - -## Easy mistakes - -1. **The PR opened against `main`.** `--base next` was forgotten or the base was not changed on the - website, so the PR shows the whole difference between `next` and `main`. - Fix: `gh pr edit --base next`. -2. **The branch was created from `main`.** `git switch -c feat/x` while on `main` branches off `main`, - and a PR into `next` drags in extra commits. Always name the base explicitly (`origin/next`) - instead of relying on the current branch. - Fix: `git rebase --onto origin/next origin/main feat/x`. -3. **Pushing straight to `next`.** Push only task branches; changes reach `next` through PRs, - just like `main`. -4. **Hotfixes on `main` never reach `next`.** Urgent fixes to the legacy SDK go in a branch from - `main` with a PR into `main`. So that they are not lost, merge `main` into `next` from time to time - in a separate PR: - - ```bash - git switch -c chore/sync-main-into-next origin/next - git merge origin/main - git push -u origin chore/sync-main-into-next - gh pr create --base next - ``` - -5. **A stale local `next`.** It only moves on `git pull`. For new branches and rebases it is safer - to use `origin/next` right after `git fetch`. - -## CI and releases - -- CI (`.github/workflows/ci.yml`) runs on pushes to `main` and `next` and on every PR, stacked ones included. -- Dependabot opens update PRs against `next` (`target-branch` in `.github/dependabot.yml`). - After `next` is merged into `main`, set `target-branch: main` again. -- The gem is not published to RubyGems from `next` until the new API reaches production. - -## If `next` lives for a long time - -The default branch on GitHub (Settings → Branches) can be switched to `next`: PRs then target it -by default and mistake 1 goes away. This changes the default base for the whole team, so it is a -team decision, not one developer's. diff --git a/docs/migration-next.md b/docs/migration-next.md deleted file mode 100644 index d581640..0000000 --- a/docs/migration-next.md +++ /dev/null @@ -1,75 +0,0 @@ -# Migrating from 0.2.x (legacy API) to 0.3 (next API) - -0.3 targets the next-generation OctaSpace API and does not work with the legacy one: the two APIs -differ in authentication, identifiers, money units and most payloads. Keep 0.2.x for code that still -talks to the legacy production hosts. - -## Client and authentication - -| 0.2.x | 0.3 | -|---|---| -| Default `base_url` `https://api.octa.space` | `https://api-dev.octa.space` (`OctaSpace::BASE_URL_DEV`), the test stand of the new API | -| `Authorization: ` | `Authorization: Bearer ` | -| Account token (64 hex characters) | API key `octa_ak_…` from Cube → Profile → Credentials; legacy tokens are refused | -| Resource methods return `OctaSpace::Response` | They return typed models (`Types::*`), a uuid, or `nil` | -| `response.data` | `model.to_h` (raw JSON) or `model[:field]` | - -## Resources - -| 0.2.x | 0.3 | -|---|---| -| `accounts.profile`, `accounts.generate_wallet` | removed (no public endpoint; `accounts.balance` is the way to check a key) | -| `accounts.balance` → `{balance}` in Wei | `accounts.balance` → `Types::Balance` (`balance` integer micro-USD, `currency`, `decimals`) | -| `nodes.list`, `nodes.find(id)` | `hosting.nodes` (online nodes of the account only, no per-node endpoint) | -| `nodes.update_prices(id, …)` | `hosting.set_prices(number, base:, storage:, traffic:, min_rent_minutes:)` (integer micro-USD) | -| `nodes.download_ident`, `download_logs`, `reboot` | removed | -| `services.mr.list` | `compute.list` | -| `services.mr.create(node_id:, …)` | `compute.start(node_number:, image:, …)` → uuid; `compute.start_batch(items)` | -| `start_command` | `entrypoint` + `args` | -| — | `compute.restart(uuid, **params)`; `ssh:`, `fm:`, `startup_script:` on start | -| `services.render.list` / `create(node_id:, disk_size:)` | `render.list` / `render.start(node_number:)`, `render.start_batch` | -| `services.vpn.*` | removed (no VPN in the public API yet) | -| `services.session(uuid).info` | `sessions.get(uuid)` | -| `services.session(uuid).logs(recent:)` | `sessions.logs(uuid, tail:)` (running sessions only) | -| `services.session(uuid).stop(score:)` | `sessions.stop(uuid)` → 202; `sessions.wait_stopped(uuid)` | -| `sessions.list(recent: true)` | `sessions.list` (running sessions only; no history) | -| `apps.list` | removed | -| `idle_jobs.*` | removed | -| `network.info` (legacy shape) | `network.info` → `Types::Network` (new shape) | -| — | `stats.gpu_current`, `stats.gpu_history`, `stats.gpu_locations` | - -## Data - -- **Node identifier.** The integer `node_id` is gone everywhere. Nodes are identified by - `node_number`, a string like `M95S-WZG8-7` (the API ignores case, dashes and spaces). The API has - no mapping from old ids. -- **Money.** Wei, ETH and USD floats are replaced by integer micro-USD (1 = $0.000001) in every price, - charge and balance. Use `OctaSpace::Money` to convert and format. `*_usd`, `*_ether`, `total_price_*` - and `market_price` are gone. -- **Session state.** `is_ready` is replaced by `status` (`"starting"` / `"ready"`, `ready?`), with - `progress` and `pull_percent` while starting. `ssh_direct`/`ssh_proxy`/`ssh_web` are grouped in - `ssh` (`direct`, `proxy`, `web`); `user_envs` is `envs`; `ports_matrix` is `ports`. -- **Bandwidth.** `net_down_mbps` / `net_up_mbps` are integer Mbit/s; the old `net_*_mbits` - (bytes per second) and `PayloadHelpers.normalize_marketplace_bandwidth` are gone. -- **Time.** `started_at` and log `ts` are Unix milliseconds, `at` is Unix seconds, `online_at` is - ISO 8601; the models offer `started_time`, `time` and `online_time`. - -## Errors - -- 400 is now `OctaSpace::ValidationError` (previously a plain `ApiError`), 409 is the new - `OctaSpace::ConflictError`. -- `OctaSpace::ProvisionRejectedError` is gone: a single `compute.start` raises - `ValidationError` with the API's reason, and `start_batch` reports failures per item. -- `ApiError#retry_after` and `#rate_limit` are available on every API error; `client.rate_limit` - gives the latest `RateLimit-*` state. - -## Behaviour - -- Only GET requests and `sessions.stop` are retried. Starting or restarting a service is never - retried, and a request that changes state is never sent to a second base URL. -- Methods that need a key raise `AuthenticationError` before the request when none is configured. -- `on_request` no longer receives request headers, and the logger no longer prints them. -- Ruby 3.3 or newer is required; the playground app, `MockTransport` and the Rails Engine are gone. - -Background, field by field: the shared API difference document used for all OctaSpace SDKs -(go-sdk `.tmp/Next/API-NEXT-DIFF.md`). diff --git a/lib/octaspace.rb b/lib/octaspace.rb index 1b72564..1d084c2 100644 --- a/lib/octaspace.rb +++ b/lib/octaspace.rb @@ -28,7 +28,7 @@ # OctaSpace Ruby SDK # -# Official Ruby client for the next-generation OctaSpace API. +# 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. diff --git a/lib/octaspace/configuration.rb b/lib/octaspace/configuration.rb index 2fffe8e..a7bcc5c 100644 --- a/lib/octaspace/configuration.rb +++ b/lib/octaspace/configuration.rb @@ -1,15 +1,10 @@ # frozen_string_literal: true module OctaSpace - # Test stand of the next-generation OctaSpace API. It is the default base URL - # until the production host of the new API is announced: the production hosts - # (+api.octa.computer+, +api.octa.space+) still serve the legacy API. - BASE_URL_DEV = "https://api-dev.octa.space" - # Default API base URL - DEFAULT_BASE_URL = BASE_URL_DEV + DEFAULT_BASE_URL = "https://api-dev.octa.space" - # Prefix of the account API keys issued in Cube (Profile → Credentials) + # Prefix of account API keys (created in Cube → Profile → Credentials) API_KEY_PREFIX = "octa_ak_" # Global and per-client configuration for the OctaSpace SDK @@ -153,10 +148,6 @@ def api_key=(value) # @return [Boolean] whether an API key is configured def api_key? = !api_key.nil? - # Alias: `persistent` is the Cube-internal term; `keep_alive` is the public SDK term - alias_method :persistent, :keep_alive - alias_method :persistent=, :keep_alive= - # @return [Boolean] def keep_alive? = !!keep_alive diff --git a/lib/octaspace/errors.rb b/lib/octaspace/errors.rb index 89fa18e..cefa840 100644 --- a/lib/octaspace/errors.rb +++ b/lib/octaspace/errors.rb @@ -32,7 +32,7 @@ class TimeoutError < NetworkError; end 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 + # read. The API answers errors as +{"message": "..."}+; the message is # used as the exception message. class ApiError < Error # @return [Integer, nil] seconds to wait before retrying (Retry-After header) @@ -55,8 +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. The next API expects - # +Authorization: Bearer octa_ak_…+; legacy account tokens are refused. + # 401: the API key is missing or invalid. 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 @@ -117,8 +116,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 += " (the next API expects a key starting with \"#{API_KEY_PREFIX}\"; " \ - "legacy account tokens are not accepted)" + message += " (API keys start with \"#{API_KEY_PREFIX}\"; create one in Cube → Profile → Credentials)" end klass.new(message, response: response) end diff --git a/lib/octaspace/json_null.rb b/lib/octaspace/json_null.rb index 04e59b5..ba82d6a 100644 --- a/lib/octaspace/json_null.rb +++ b/lib/octaspace/json_null.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module OctaSpace - # Repairs for values the next API sometimes sends with the wrong JSON type. + # 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 diff --git a/lib/octaspace/model.rb b/lib/octaspace/model.rb index bc1fdf7..47dc6ca 100644 --- a/lib/octaspace/model.rb +++ b/lib/octaspace/model.rb @@ -10,7 +10,7 @@ module OctaSpace # # @example # machine = client.compute.list.first - # machine.node_number # => "M95S-WZG8-7" + # machine.node_number # => "7KQ4-M2XD-4" # machine.gpus.first.model # => "NVIDIA GeForce RTX 3090" # machine[:new_field] # => value of a field the SDK has no reader for # machine.to_h # => the JSON object as parsed diff --git a/lib/octaspace/resources/compute.rb b/lib/octaspace/resources/compute.rb index 0b1a168..44cc6dc 100644 --- a/lib/octaspace/resources/compute.rb +++ b/lib/octaspace/resources/compute.rb @@ -33,7 +33,7 @@ def list # available", …) raise {OctaSpace::ValidationError}. Never retried # automatically: a start costs money. # - # @param node_number [String] node number from the listing, e.g. "M95S-WZG8-7" + # @param node_number [String] node number from the listing, e.g. "7KQ4-M2XD-4" # (case, dashes and spaces are ignored by the API) # @param image [String] container image, e.g. "ubuntu:24.04" # @param app [String, nil] application uuid diff --git a/lib/octaspace/resources/hosting.rb b/lib/octaspace/resources/hosting.rb index d8cbcf9..940cf71 100644 --- a/lib/octaspace/resources/hosting.rb +++ b/lib/octaspace/resources/hosting.rb @@ -6,7 +6,7 @@ module Resources # # @example # client.hosting.nodes.each { |node| puts "#{node.number} #{node.status}" } - # client.hosting.set_prices("M95S-WZG8-7", base: OctaSpace::Money.from_usd("0.25")) + # client.hosting.set_prices("7KQ4-M2XD-4", base: OctaSpace::Money.from_usd("0.25")) class Hosting < Base PRICE_FIELDS = %i[base storage traffic min_rent_minutes].freeze diff --git a/lib/octaspace/resources/sessions.rb b/lib/octaspace/resources/sessions.rb index ac66f53..90d92d8 100644 --- a/lib/octaspace/resources/sessions.rb +++ b/lib/octaspace/resources/sessions.rb @@ -9,7 +9,7 @@ module Resources # session history. # # @example - # uuid = client.compute.start(node_number: "M95S-WZG8-7", image: "ubuntu:24.04") + # uuid = client.compute.start(node_number: "7KQ4-M2XD-4", 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) diff --git a/lib/octaspace/types/hosting.rb b/lib/octaspace/types/hosting.rb index 9e33679..9f3639f 100644 --- a/lib/octaspace/types/hosting.rb +++ b/lib/octaspace/types/hosting.rb @@ -63,7 +63,7 @@ class Location < Model # An online node of the account (+GET /hosting/nodes+) class HostingNode < Model # @!attribute [r] number - # @return [String] node number, e.g. "M95S-WZG8-7" (the one renters see) + # @return [String] node number, e.g. "7KQ4-M2XD-4" (the one renters see) # @!attribute [r] status # @return [String] "idle", "rented", "verification", "benchmark" or "unknown" # @!attribute [r] verified diff --git a/lib/octaspace/types/machines.rb b/lib/octaspace/types/machines.rb index 7391f2f..0c502f9 100644 --- a/lib/octaspace/types/machines.rb +++ b/lib/octaspace/types/machines.rb @@ -31,7 +31,7 @@ class Reliability < Model # Fields shared by compute and render listings class Machine < Model # @!attribute [r] node_number - # @return [String] node number, e.g. "M95S-WZG8-7" + # @return [String] node number, e.g. "7KQ4-M2XD-4" # @!attribute [r] base_price # @return [Integer] price per hour, micro-USD # @!attribute [r] min_rent_minutes diff --git a/test/fixtures/next/account_balance.json b/test/fixtures/account_balance.json similarity index 100% rename from test/fixtures/next/account_balance.json rename to test/fixtures/account_balance.json diff --git a/test/fixtures/next/batch.json b/test/fixtures/batch.json similarity index 100% rename from test/fixtures/next/batch.json rename to test/fixtures/batch.json diff --git a/test/fixtures/next/hosting_nodes.json b/test/fixtures/hosting_nodes.json similarity index 100% rename from test/fixtures/next/hosting_nodes.json rename to test/fixtures/hosting_nodes.json diff --git a/test/fixtures/next/network.json b/test/fixtures/network.json similarity index 100% rename from test/fixtures/next/network.json rename to test/fixtures/network.json diff --git a/test/fixtures/next/services_compute.json b/test/fixtures/services_compute.json similarity index 100% rename from test/fixtures/next/services_compute.json rename to test/fixtures/services_compute.json diff --git a/test/fixtures/next/services_render.json b/test/fixtures/services_render.json similarity index 100% rename from test/fixtures/next/services_render.json rename to test/fixtures/services_render.json diff --git a/test/fixtures/next/session_compute_net_mbps.json b/test/fixtures/session_compute_net_mbps.json similarity index 100% rename from test/fixtures/next/session_compute_net_mbps.json rename to test/fixtures/session_compute_net_mbps.json diff --git a/test/fixtures/next/session_compute_ready.json b/test/fixtures/session_compute_ready.json similarity index 100% rename from test/fixtures/next/session_compute_ready.json rename to test/fixtures/session_compute_ready.json diff --git a/test/fixtures/next/session_compute_ssh_proxy.json b/test/fixtures/session_compute_ssh_proxy.json similarity index 100% rename from test/fixtures/next/session_compute_ssh_proxy.json rename to test/fixtures/session_compute_ssh_proxy.json diff --git a/test/fixtures/next/session_compute_starting.json b/test/fixtures/session_compute_starting.json similarity index 100% rename from test/fixtures/next/session_compute_starting.json rename to test/fixtures/session_compute_starting.json diff --git a/test/fixtures/next/session_logs.json b/test/fixtures/session_logs.json similarity index 100% rename from test/fixtures/next/session_logs.json rename to test/fixtures/session_logs.json diff --git a/test/fixtures/next/stats_gpu_current.json b/test/fixtures/stats_gpu_current.json similarity index 100% rename from test/fixtures/next/stats_gpu_current.json rename to test/fixtures/stats_gpu_current.json diff --git a/test/fixtures/next/stats_gpu_history.json b/test/fixtures/stats_gpu_history.json similarity index 100% rename from test/fixtures/next/stats_gpu_history.json rename to test/fixtures/stats_gpu_history.json diff --git a/test/fixtures/next/stats_gpu_locations.json b/test/fixtures/stats_gpu_locations.json similarity index 100% rename from test/fixtures/next/stats_gpu_locations.json rename to test/fixtures/stats_gpu_locations.json diff --git a/test/octaspace/configuration_test.rb b/test/octaspace/configuration_test.rb index 1d48a2e..59bc7a1 100644 --- a/test/octaspace/configuration_test.rb +++ b/test/octaspace/configuration_test.rb @@ -21,13 +21,6 @@ def test_keep_alive_predicate assert config.keep_alive? end - def test_persistent_alias - config = OctaSpace::Configuration.new - config.persistent = true - assert config.keep_alive? - assert config.persistent - end - def test_urls_single_base_url config = OctaSpace::Configuration.new config.base_url = "https://api.octa.space" diff --git a/test/octaspace/resources/accounts_test.rb b/test/octaspace/resources/accounts_test.rb index 55ff6d4..de6d5d5 100644 --- a/test/octaspace/resources/accounts_test.rb +++ b/test/octaspace/resources/accounts_test.rb @@ -4,7 +4,7 @@ class OctaSpace::AccountsResourceTest < Minitest::Test def test_balance - stub_get("/accounts/balance", fixture_path: "next/account_balance.json") + stub_get("/accounts/balance", fixture_path: "account_balance.json") balance = test_client.accounts.balance assert_equal 1_234_567, balance.balance diff --git a/test/octaspace/resources/compute_test.rb b/test/octaspace/resources/compute_test.rb index f45a3a3..8a4908a 100644 --- a/test/octaspace/resources/compute_test.rb +++ b/test/octaspace/resources/compute_test.rb @@ -14,7 +14,7 @@ def setup # --- list --- def test_list_returns_machines_from_live_fixture - stub_get("/services/compute", fixture_path: "next/services_compute.json") + stub_get("/services/compute", fixture_path: "services_compute.json") machines = @client.compute.list assert_equal 3, machines.size @@ -32,7 +32,7 @@ def test_list_returns_machines_from_live_fixture end def test_list_turns_null_strings_into_nil - stub_get("/services/compute", fixture_path: "next/services_compute.json") + stub_get("/services/compute", fixture_path: "services_compute.json") arc = @client.compute.list.find { |x| x.gpus.any? { |g| g.model.to_s.include?("Arc") } } assert_nil arc.gpus.first.mem_clock @@ -112,7 +112,7 @@ def test_start_is_not_retried def test_start_batch_assigns_positions_and_parses_results stub_request(:post, "#{BASE}/services/compute") .with(body: [{id: "one", node_number: "A", image: "x"}, {id: 1, node_number: "B", image: "x"}].to_json) - .to_return(status: 200, body: fixture("next/batch.json")) + .to_return(status: 200, body: fixture("batch.json")) results = @client.compute.start_batch([{id: "one", node_number: "A", image: "x"}, {node_number: "B", image: "x"}]) assert_equal 3, results.size diff --git a/test/octaspace/resources/hosting_test.rb b/test/octaspace/resources/hosting_test.rb index decfa15..3a2b52d 100644 --- a/test/octaspace/resources/hosting_test.rb +++ b/test/octaspace/resources/hosting_test.rb @@ -11,7 +11,7 @@ def setup end def test_nodes - stub_get("/hosting/nodes", fixture_path: "next/hosting_nodes.json") + stub_get("/hosting/nodes", fixture_path: "hosting_nodes.json") node = @client.hosting.nodes.find { |n| n.number == "M95S-WZG8-7" } assert node.idle? diff --git a/test/octaspace/resources/network_test.rb b/test/octaspace/resources/network_test.rb index 854d32a..1846849 100644 --- a/test/octaspace/resources/network_test.rb +++ b/test/octaspace/resources/network_test.rb @@ -4,7 +4,7 @@ class OctaSpace::NetworkResourceTest < Minitest::Test def test_info_without_key - stub_get("/network", fixture_path: "next/network.json") + stub_get("/network", fixture_path: "network.json") info = OctaSpace::Client.new.network.info assert_equal 3, info.nodes.online @@ -19,7 +19,7 @@ def test_info_without_key end def test_info_never_sends_key - stub_get("/network", fixture_path: "next/network.json") + stub_get("/network", fixture_path: "network.json") test_client.network.info assert_requested(:get, "#{StubHelpers::BASE_URL}/network") { |req| !req.headers.key?("Authorization") } end diff --git a/test/octaspace/resources/render_test.rb b/test/octaspace/resources/render_test.rb index 73f0fb6..24d4525 100644 --- a/test/octaspace/resources/render_test.rb +++ b/test/octaspace/resources/render_test.rb @@ -11,7 +11,7 @@ def setup end def test_list_returns_render_machines - stub_get("/services/render", fixture_path: "next/services_render.json") + stub_get("/services/render", fixture_path: "services_render.json") machines = @client.render.list assert(machines.all?(OctaSpace::Types::RenderMachine)) @@ -48,7 +48,7 @@ def test_start_batch_reports_unavailable_per_item assert_equal "Service is temporarily unavailable", results.first.reason end - def test_start_batch_rejects_legacy_fields + def test_start_batch_rejects_unknown_fields assert_raises(ArgumentError) { @client.render.start_batch([{node_number: "A", disk_size: 100}]) } end end diff --git a/test/octaspace/resources/sessions_test.rb b/test/octaspace/resources/sessions_test.rb index caeb5c6..84a131f 100644 --- a/test/octaspace/resources/sessions_test.rb +++ b/test/octaspace/resources/sessions_test.rb @@ -18,7 +18,7 @@ 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")), + body = [JSON.parse(fixture("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) @@ -36,7 +36,7 @@ def test_list_requires_api_key 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")) + stub_request(:get, info_url).to_return(status: 200, body: fixture("session_compute_ssh_proxy.json")) s = @client.sessions.get(UUID) assert s.compute? @@ -54,7 +54,7 @@ def test_get_parses_a_ready_compute_session end def test_starting_session_has_nil_fields - stub_request(:get, info_url).to_return(status: 200, body: fixture("next/session_compute_starting.json")) + stub_request(:get, info_url).to_return(status: 200, body: fixture("session_compute_starting.json")) s = @client.sessions.get(UUID) assert s.starting? @@ -62,7 +62,7 @@ def test_starting_session_has_nil_fields 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")) + stub_request(:get, info_url).to_return(status: 200, body: fixture("session_compute_net_mbps.json")) hw = @client.sessions.get(UUID).node_hw assert_kind_of Integer, hw.net_down_mbps @@ -70,7 +70,7 @@ def test_net_speed_in_node_hw end def test_inspect_redacts_access_urls - data = JSON.parse(fixture("next/session_compute_ssh_proxy.json")) + data = JSON.parse(fixture("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) @@ -98,7 +98,7 @@ def test_uuid_is_path_encoded # --- logs --- def test_logs - stub_request(:get, "#{BASE}/services/#{UUID}/logs").to_return(status: 200, body: fixture("next/session_logs.json")) + stub_request(:get, "#{BASE}/services/#{UUID}/logs").to_return(status: 200, body: fixture("session_logs.json")) logs = @client.sessions.logs(UUID) assert_kind_of OctaSpace::Types::LogEntry, logs.system.first @@ -137,8 +137,8 @@ def test_stop_is_retried_on_transient_errors 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")} + {status: 200, body: fixture("session_compute_starting.json")}, + {status: 200, body: fixture("session_compute_ready.json")} ) seen = [] session = @sessions.wait_ready(UUID, interval: 2) { |s| seen << s.status } @@ -150,7 +150,7 @@ def test_wait_ready_polls_and_yields_progress 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: 200, body: fixture("session_compute_starting.json")}, {status: 404, body: '{"message":"Session not found"}'} ) @@ -160,14 +160,14 @@ def test_wait_ready_raises_when_session_stops end def test_wait_ready_times_out - stub_request(:get, info_url).to_return(status: 200, body: fixture("next/session_compute_starting.json")) + stub_request(:get, info_url).to_return(status: 200, body: fixture("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: 200, body: fixture("session_compute_ready.json")}, {status: 404, body: '{"message":"Session not found"}'} ) diff --git a/test/octaspace/resources/stats_test.rb b/test/octaspace/resources/stats_test.rb index 45a86f9..6c214c7 100644 --- a/test/octaspace/resources/stats_test.rb +++ b/test/octaspace/resources/stats_test.rb @@ -12,7 +12,7 @@ def setup def test_gpu_current stub_request(:get, "#{BASE}/stats/gpu/current").with(query: {"verified" => "yes"}) - .to_return(status: 200, body: fixture("next/stats_gpu_current.json")) + .to_return(status: 200, body: fixture("stats_gpu_current.json")) market = @client.stats.gpu_current(verified: :yes) gpu = market.gpus.first @@ -34,7 +34,7 @@ def test_gpu_history_params_and_points from = Time.at(1_793_000_000) stub_request(:get, "#{BASE}/stats/gpu/history") .with(query: {"gpu_name" => "Arc B580,NVIDIA GeForce RTX 3090", "start" => "1793000000", "end" => "1793086400", "step" => "3600"}) - .to_return(status: 200, body: fixture("next/stats_gpu_history.json")) + .to_return(status: 200, body: fixture("stats_gpu_history.json")) history = @client.stats.gpu_history(gpu_name: ["Arc B580", "NVIDIA GeForce RTX 3090"], from: from, to: 1_793_086_400, step: 3600) arc = history.fetch("Arc B580") @@ -57,7 +57,7 @@ def test_gpu_history_validation_error end def test_gpu_locations - stub_get("/stats/gpu/locations", fixture_path: "next/stats_gpu_locations.json") + stub_get("/stats/gpu/locations", fixture_path: "stats_gpu_locations.json") loc = @client.stats.gpu_locations.first assert_equal "Mukachevo", loc.city diff --git a/test/octaspace/transport/faraday_transport_test.rb b/test/octaspace/transport/faraday_transport_test.rb index 6bcfdf5..0202e39 100644 --- a/test/octaspace/transport/faraday_transport_test.rb +++ b/test/octaspace/transport/faraday_transport_test.rb @@ -112,12 +112,12 @@ def test_auth_none_never_sends_key assert_requested(:get, "#{BASE}/network") { |req| !req.headers.key?("Authorization") } end - def test_401_with_legacy_key_adds_hint + def test_401_with_non_octa_ak_key_adds_hint @config.api_key = "a" * 64 stub_request(:get, "#{BASE}/accounts/balance").to_return(status: 401, body: '{"message":"Invalid bearer token"}') error = assert_raises(OctaSpace::AuthenticationError) { transport.get("/accounts/balance") } - assert_match(/\AInvalid bearer token \(the next API expects a key starting with "octa_ak_"/, error.message) + assert_match(/\AInvalid bearer token \(API keys start with "octa_ak_"/, error.message) end def test_401_with_new_key_has_no_hint From 82cf19634f84a7e1cf233ffcc477c4fb001a6eef Mon Sep 17 00:00:00 2001 From: Aleksander Date: Fri, 6 Nov 2026 12:28:31 +0200 Subject: [PATCH 9/9] docs: user guides, dashboard terms for API keys, LICENSE (#9) README, guides under docs/, CHANGELOG, YARD comments, gem metadata and LICENSE for users of the gem; placeholder data in test fixtures. --- .gitignore | 2 - CHANGELOG.md | 126 +++++---- LICENSE | 21 ++ MIT-LICENSE | 22 -- README.md | 265 ++++++++---------- 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 | 11 +- 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 +- 46 files changed, 925 insertions(+), 399 deletions(-) create mode 100644 LICENSE delete mode 100644 MIT-LICENSE 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/.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/ 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/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..2b8a586 100644 --- a/README.md +++ b/README.md @@ -1,195 +1,154 @@ -# 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)](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. +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 -[MIT](MIT-LICENSE) +[MIT](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 47b0d0c..ef537ab 100644 --- a/octaspace.gemspec +++ b/octaspace.gemspec @@ -8,22 +8,23 @@ 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" } - 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" 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