summaryrefslogtreecommitdiff
path: root/lib/hyperstack/client.rb
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-25 10:43:43 +0200
committerPaul Buetow <paul@buetow.org>2026-03-25 10:43:43 +0200
commitef53a98c39c26d69b4bfd3a4e925050b220a02c9 (patch)
treed6e747f4a9eea844f498b3f807567d3a5330694e /lib/hyperstack/client.rb
parent917c3d9a777d343b422599f291f242f4bf025ba0 (diff)
hyperstack: split 3335-line monolith into lib/hyperstack/ modules
Extracts all classes from hyperstack.rb into focused library files: - lib/hyperstack/config.rb — ConfigLoader + Config (TOML loading, validation) - lib/hyperstack/state.rb — StateStore + PrefixedOutput (JSON state, threaded output) - lib/hyperstack/client.rb — HyperstackClient (REST API + retry logic) - lib/hyperstack/wireguard.rb — LocalWireGuard (wg1.conf peer management, /etc/hosts) - lib/hyperstack/provisioning.rb — ProvisioningScripts + RemoteProvisioner (SSH bootstrap) - lib/hyperstack/manager.rb — Manager (VM lifecycle orchestration) - lib/hyperstack/watcher.rb — VllmWatcher (Prometheus + GPU dashboard) - lib/hyperstack/cli.rb — CLI (OptionParser command dispatch) hyperstack.rb becomes a 46-line entry point with require_relative calls. All files pass `ruby -c` syntax check and `--help` runs correctly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'lib/hyperstack/client.rb')
-rw-r--r--lib/hyperstack/client.rb140
1 files changed, 140 insertions, 0 deletions
diff --git a/lib/hyperstack/client.rb b/lib/hyperstack/client.rb
new file mode 100644
index 0000000..b7b4a6b
--- /dev/null
+++ b/lib/hyperstack/client.rb
@@ -0,0 +1,140 @@
+# frozen_string_literal: true
+
+require 'json'
+require 'net/http'
+require 'openssl'
+require 'socket'
+require 'timeout'
+
+module HyperstackVM
+ # HTTP client for the Hyperstack (NexGenCloud) REST API.
+ # Handles authentication, JSON encoding/decoding, and retry logic with exponential back-off.
+ class HyperstackClient
+ def initialize(base_url:, api_key:)
+ @base_uri = URI(base_url)
+ @api_key = api_key
+ end
+
+ def list_environments
+ response = request(:get, '/core/environments')
+ response.fetch('environments', [])
+ end
+
+ def list_keypairs
+ response = request(:get, '/core/keypairs')
+ response.fetch('keypairs', [])
+ end
+
+ def list_flavors
+ response = request(:get, '/core/flavors')
+ Array(response['data']).flat_map do |entry|
+ Array(entry['flavors']).map do |flavor|
+ flavor.merge(
+ 'region_name' => flavor['region_name'] || entry['region_name'],
+ 'gpu' => flavor['gpu'] || entry['gpu']
+ )
+ end
+ end
+ end
+
+ def list_images
+ response = request(:get, '/core/images')
+ Array(response['images']).flat_map do |entry|
+ Array(entry['images']).map do |image|
+ image.merge(
+ 'region_name' => image['region_name'] || entry['region_name'],
+ 'type' => image['type'] || entry['type']
+ )
+ end
+ end
+ end
+
+ def list_vms
+ response = request(:get, '/core/virtual-machines')
+ response.fetch('instances', [])
+ end
+
+ def get_vm(vm_id)
+ response = request(:get, "/core/virtual-machines/#{vm_id}")
+ response.fetch('instance', nil)
+ end
+
+ def create_vm(payload)
+ request(:post, '/core/virtual-machines', payload)
+ end
+
+ def delete_vm(vm_id)
+ request(:delete, "/core/virtual-machines/#{vm_id}")
+ end
+
+ def create_vm_rule(vm_id, payload)
+ request(:post, "/core/virtual-machines/#{vm_id}/sg-rules", payload)
+ end
+
+ def delete_vm_rule(vm_id, rule_id)
+ request(:delete, "/core/virtual-machines/#{vm_id}/sg-rules/#{rule_id}")
+ end
+
+ private
+
+ def request(method, path, payload = nil)
+ uri = @base_uri.dup
+ uri.path = "#{@base_uri.path}#{path}"
+
+ request = case method
+ when :get
+ Net::HTTP::Get.new(uri)
+ when :post
+ Net::HTTP::Post.new(uri)
+ when :delete
+ Net::HTTP::Delete.new(uri)
+ else
+ raise Error, "Unsupported HTTP method: #{method}"
+ end
+
+ request['accept'] = 'application/json'
+ request['api_key'] = @api_key
+ if payload
+ request['content-type'] = 'application/json'
+ request.body = JSON.generate(payload)
+ end
+
+ retries_left = 4
+ begin
+ response = Net::HTTP.start(
+ uri.host,
+ uri.port,
+ use_ssl: uri.scheme == 'https',
+ open_timeout: 30,
+ read_timeout: 120
+ ) { |http| http.request(request) }
+
+ parse_response(response)
+ rescue Timeout::Error, Errno::ECONNREFUSED, Errno::ECONNRESET,
+ Errno::EHOSTUNREACH, Errno::ENETUNREACH,
+ SocketError, OpenSSL::SSL::SSLError, Net::OpenTimeout => e
+ raise Error, "Hyperstack API request failed for #{path}: #{e.message}" if retries_left <= 0
+
+ retries_left -= 1
+ delay = (4 - retries_left) * 5
+ warn "API request to #{path} failed (#{e.class}: #{e.message}), retrying in #{delay}s (#{retries_left} left)..."
+ sleep delay
+ retry
+ end
+ end
+
+ def parse_response(response)
+ body = response.body.to_s
+ payload = body.empty? ? {} : JSON.parse(body)
+
+ if response.code.to_i >= 400 || payload['status'] == false
+ message = payload['message'] || payload['error_reason'] || response.message
+ raise Error, "Hyperstack API error (HTTP #{response.code}): #{message}"
+ end
+
+ payload
+ rescue JSON::ParserError => e
+ raise Error, "Failed to parse Hyperstack API response: #{e.message}"
+ end
+ end
+end