From 1b4098c7e00e2e48e1b58a2633865c5ec823b1d6 Mon Sep 17 00:00:00 2001 From: secretpray Date: Tue, 3 Nov 2026 12:52:25 +0200 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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