summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-25 20:05:15 +0300
committerPaul Buetow <paul@buetow.org>2026-05-25 20:05:15 +0300
commit4fd86fbc75670878308fd6a56b7778334b52ccd8 (patch)
tree1f2a611c41248844d9f7e32f4bb78760001cc8cb
parentaa298b3d85a7f4ee7f1e1b4a3192421b95f67828 (diff)
refactor(manager): extract focused collaborators from Manager God Class
Extract VM lifecycle, SSH execution, WireGuard setup, model switching, end-to-end inference tests, and provisioning orchestration into separate collaborator classes. Manager becomes a thin facade (~80 lines). Also fixes CLI edge-cases: status/model-list when no VMs are active, and threads --vllm/--ollama flags through the provisioning pipeline.
-rw-r--r--README.md15
-rw-r--r--hyperstack-vm1.toml20
-rw-r--r--hyperstack-vm2.toml29
-rw-r--r--hypr.fish1
-rw-r--r--lib/hyperstack/cli.rb18
-rw-r--r--lib/hyperstack/inference_tester.rb79
-rw-r--r--lib/hyperstack/manager.rb1066
-rw-r--r--lib/hyperstack/model_switcher.rb72
-rw-r--r--lib/hyperstack/provisioning_orchestrator.rb187
-rw-r--r--lib/hyperstack/ssh_runner.rb126
-rw-r--r--lib/hyperstack/vm_lifecycle.rb414
-rw-r--r--lib/hyperstack/wireguard_setup.rb109
12 files changed, 1139 insertions, 997 deletions
diff --git a/README.md b/README.md
index 39c669e..a27ddbd 100644
--- a/README.md
+++ b/README.md
@@ -46,7 +46,7 @@ Runs two A100 VMs concurrently — each serving a different model — with [Pi](
│ │ │ │
│ vLLM :11434 │ │ vLLM :11434 │
│ Qwen3.6 27B FP8 │ │ Gemma 4 31B IT │
- │ (MoE, AWQ-4bit) │ │ (dense, AWQ-4bit) │
+ │ (dense, FP8) │ │ (dense, AWQ-4bit) │
└──────────────────────────┘ └──────────────────────────┘
```
@@ -166,8 +166,8 @@ definitions are available without any manual config editing.
Source `hyperstack.fish` or copy the abbreviations into your Fish config:
```fish
-abbr pi-hyperstack pi --model hyperstack/Qwen/Qwen3.6-27B-FP8
-abbr pi-hyperstack-coder pi --model hyperstack1/Qwen/Qwen3.6-27B-FP8
+abbr pi-hyperstack pi --model hyperstack1/Qwen/Qwen3.6-27B-FP8
+abbr pi-hyperstack-coder pi --model hyperstack1/Qwen/Qwen3.6-27B-FP8
abbr pi-hyperstack-qwen36 pi --model hyperstack2/Qwen/Qwen3.6-27B-FP8
abbr pi-hyperstack-gemma4 pi --model hyperstack2/cyankiwi/gemma-4-31B-it-AWQ-4bit
```
@@ -176,7 +176,7 @@ Then launch a session after the VM(s) are up:
```fish
pi-hyperstack # Qwen3.6 27B FP8 on VM1
-pi-hyperstack-coder # Qwen3.6 27B FP8 on VM1
+pi-hyperstack-coder # Qwen3.6 27B FP8 on VM1
pi-hyperstack-qwen36 # Qwen3.6 27B FP8 on VM2
pi-hyperstack-gemma4 # Gemma 4 31B on VM2
```
@@ -280,7 +280,10 @@ Available presets (both VMs share the same set):
|---|---|---|---|
| `gemma4-31b` | Gemma 4 31B IT (AWQ-4bit) | ~19 GB | 32K–128K (see TOML) |
| `nemotron-super` | Nemotron-3-Super 120B (Mamba+MoE, 12B active) | ~60 GB | 131K |
+| `qwen36-35b-a3b` | Qwen3.6-35B-A3B MoE (AWQ, 3B active) | ~18 GB | 65K* |
| `qwen36-27b` | Qwen3.6 27B FP8 | ~45 GB | 262K |
+
+\* Needs a quantized checkpoint on HuggingFace before it can run on a single GPU.
| `qwen25-coder-32b` | Qwen2.5-Coder-32B-Instruct (AWQ) | ~18 GB | 32K |
| `qwen3-coder-30b` | Qwen3-Coder-30B-A3B (MoE, AWQ) | ~18 GB | 65K |
| `deepseek-r1-32b` | DeepSeek-R1-Distill-Qwen-32B (AWQ) | ~18 GB | 32K |
@@ -305,6 +308,8 @@ create options:
--dry-run Print the plan without making changes
--vllm / --no-vllm Override config: enable/disable vLLM setup
--ollama / --no-ollama Override config: enable/disable Ollama setup
+ --model PRESET Use a named vLLM preset at create time
+ --flavor NAME Override GPU flavor (e.g. n3-H100x1). Both TOMLs default to n3-A100x1.
All commands accept --vm 1|2|both (default: 1).
```
@@ -347,7 +352,7 @@ ruby hyperstack.rb test --vm 1
ruby hyperstack.rb test --vm 2
# Launch Pi coding agents — one per terminal
-pi-hyperstack-coder # fish abbreviation → Qwen3.6 27B FP8 on VM1
+pi-hyperstack-coder # fish abbreviation → Qwen3.6 27B FP8 on VM1
pi-hyperstack-qwen36 # fish abbreviation → Qwen3.6 27B FP8 on VM2
pi-hyperstack-gemma4 # fish abbreviation → Gemma 4 31B on VM2
diff --git a/hyperstack-vm1.toml b/hyperstack-vm1.toml
index d28dbb8..be1107b 100644
--- a/hyperstack-vm1.toml
+++ b/hyperstack-vm1.toml
@@ -69,12 +69,30 @@ max_model_len = 262144
gpu_memory_utilization = 0.92
tensor_parallel_size = 1
tool_call_parser = "qwen3_coder"
+extra_vllm_args = ["--reasoning-parser", "qwen3"]
# Named model presets for 'ruby hyperstack.rb --vm 1 model switch <name>'.
# Each preset overrides the matching [vllm] field; unset fields fall back to [vllm] defaults.
+# Qwen3.6-35B-A3B AWQ — hybrid MoE (3B active / 35B total) on L40 48GB.
+# Requires a quantized checkpoint (~18 GB weights) to fit alongside TurboQuant KV cache.
+# Disables chunked prefill as a mitigation for vLLM issue #41726.
+[vllm.presets.qwen36-35b-a3b]
+model = "Qwen/Qwen3.6-35B-A3B-AWQ"
+container_name = "vllm_qwen36_moe"
+max_model_len = 65536
+gpu_memory_utilization = 0.92
+tensor_parallel_size = 1
+tool_call_parser = "qwen3_coder"
+docker_image = "vllm/vllm-openai:nightly"
+extra_vllm_args = [
+ "--reasoning-parser", "qwen3",
+ "--kv-cache-dtype", "turboquant_k8v4",
+ "--no-enable-chunked-prefill"
+]
+
# Qwen3.6-27B FP8 — dense 27B multimodal model with native 262K context.
-# Uses qwen3 reasoning parsing plus qwen3_coder tool calling on vLLM >=0.19.0.
+# A100/H100 fallback preset; uses qwen3 reasoning parsing plus qwen3_coder tool calling.
[vllm.presets.qwen36-27b]
model = "Qwen/Qwen3.6-27B-FP8"
container_name = "vllm_qwen36_27b"
diff --git a/hyperstack-vm2.toml b/hyperstack-vm2.toml
index f895bfc..b0211b2 100644
--- a/hyperstack-vm2.toml
+++ b/hyperstack-vm2.toml
@@ -13,13 +13,13 @@ name_prefix = "hyperstack2"
hostname = "hyperstack2"
environment_name = "snonux-ollama"
-# A100-80GB for Qwen3.6 27B; H100 fallback if n3-A100x1 unavailable.
-flavor_name = "n3-H100x1"
+# A100-80GB for Gemma 4 31B; H100 fallback if n3-A100x1 unavailable.
+flavor_name = "n3-A100x1"
image_name = "Ubuntu Server 24.04 LTS R570 CUDA 12.8 with Docker"
assign_floating_ip = true
create_bootable_volume = false
enable_port_randomization = false
-labels = ["qwen36-27b", "wireguard"]
+labels = ["gemma4-31b", "wireguard"]
[ssh]
username = "ubuntu"
@@ -58,23 +58,22 @@ context_length = 32768
pull_models = ["qwen36-27b"]
# vLLM serves one model via Docker on the OpenAI-compatible API.
-# VM2 defaults to Qwen3.6 27B; use 'model switch' to load any other preset.
-# Official Qwen3.6 guidance recommends vLLM >=0.19.0 with qwen3 reasoning parsing and
-# qwen3_coder tool parsing. The official FP8 checkpoint is the primary default here.
+# VM2 defaults to Gemma 4 31B; use 'model switch' to load any other preset.
[vllm]
install = true
-model = "Qwen/Qwen3.6-27B-FP8"
+model = "cyankiwi/gemma-4-31B-it-AWQ-4bit"
# HuggingFace model cache on ephemeral NVMe (fast; survives reboots on most providers).
hug_cache_dir = "/ephemeral/hug"
-container_name = "vllm_qwen36_27b"
-# Qwen3.6-27B-FP8: official FP8 checkpoint with native 262K context on a single 80 GB GPU.
-max_model_len = 262144
-# H100 needs 0.95 to fit Mamba cache blocks; A100 worked at 0.92.
-gpu_memory_utilization = 0.95
+container_name = "vllm_gemma4_31b"
+max_model_len = 131072
+gpu_memory_utilization = 0.92
tensor_parallel_size = 1
-tool_call_parser = "qwen3_coder"
-# --max-num-seqs 817 caps concurrent sequences to fit Mamba cache blocks on H100.
-extra_vllm_args = ["--reasoning-parser", "qwen3", "--max-num-seqs", "817"]
+tool_call_parser = "gemma4"
+# Gemma 4 requires a newer transformers and nightly vLLM image.
+docker_image = "vllm/vllm-openai:nightly"
+pre_start_cmd = "pip install -q transformers==5.5.0 2>/dev/null"
+extra_docker_env = ["CUDA_VISIBLE_DEVICES=0"]
+extra_vllm_args = []
# Named model presets for 'ruby hyperstack.rb --vm 2 model switch <name>'.
# Core model fields override the matching [vllm] values; preset-only extras such as
diff --git a/hypr.fish b/hypr.fish
index b6bb93e..d75dccb 100644
--- a/hypr.fish
+++ b/hypr.fish
@@ -1,5 +1,6 @@
# Dual-VM setup (hyperstack-vm1/vm2.toml -> hyperstack1/2.wg1)
abbr pi-hyperstack pi --model hyperstack1/Qwen/Qwen3.6-27B-FP8
+abbr pi-hyperstack-coder pi --model hyperstack1/Qwen/Qwen3.6-27B-FP8
abbr pi-hyperstack-qwen36 pi --model hyperstack2/Qwen/Qwen3.6-27B-FP8
abbr pi-hyperstack-gemma4 pi --model hyperstack2/cyankiwi/gemma-4-31B-it-AWQ-4bit
abbr hyperstack-create ruby ~/git/hyperstack/hyperstack.rb create
diff --git a/lib/hyperstack/cli.rb b/lib/hyperstack/cli.rb
index 3b85437..76f158e 100644
--- a/lib/hyperstack/cli.rb
+++ b/lib/hyperstack/cli.rb
@@ -18,7 +18,7 @@ module HyperstackVM
puts @global_parser
puts
puts 'Commands:'
- puts ' create [--replace] [--dry-run] [--vllm|--no-vllm] [--ollama|--no-ollama] [--model PRESET]'
+ puts ' create [--replace] [--dry-run] [--vllm|--no-vllm] [--ollama|--no-ollama] [--flavor NAME] [--model PRESET]'
puts ' delete [--vm-id ID] [--dry-run]'
puts ' status'
puts ' watch'
@@ -154,7 +154,7 @@ module HyperstackVM
# TOML default. Returns a hash suitable for splatting into Manager#create.
def parse_create_options(argv, include_model_preset: true)
opts = { replace: false, dry_run: false, install_vllm: nil, install_ollama: nil,
- vllm_preset: nil }
+ vllm_preset: nil, flavor_name: nil }
OptionParser.new do |o|
o.on('--replace', 'Delete the tracked VM before creating a new one') { opts[:replace] = true }
o.on('--dry-run', 'Print the create plan without creating a VM') { opts[:dry_run] = true }
@@ -162,6 +162,7 @@ module HyperstackVM
o.on('--no-vllm', 'Disable vLLM setup (overrides config)') { opts[:install_vllm] = false }
o.on('--ollama', 'Enable Ollama setup (overrides config)') { opts[:install_ollama] = true }
o.on('--no-ollama', 'Disable Ollama setup (overrides config)') { opts[:install_ollama] = false }
+ o.on('--flavor NAME', 'Override GPU flavor (e.g. n3-H100x1)') { |v| opts[:flavor_name] = v }
if include_model_preset
o.on('--model PRESET', 'Use a named vLLM preset at create time') do |v|
opts[:vllm_preset] = v
@@ -221,6 +222,7 @@ module HyperstackVM
case sub
when 'list'
loaders = default_or_active_loaders
+ loaders = selected_config_loaders if loaders.empty?
loaders.each do |loader|
if loaders.size > 1
puts
@@ -259,6 +261,13 @@ module HyperstackVM
def run_status
loaders = default_or_active_loaders
+ if loaders.empty?
+ puts 'No active VMs found.'
+ puts
+ puts '[local-wireguard]'
+ build_manager(ConfigLoader.load(vm_config_path('1')).config).show_local_wireguard(nil)
+ return
+ end
if loaders.one?
build_manager(loaders.first.config).status
return
@@ -289,7 +298,7 @@ module HyperstackVM
# VM2 adds its peer. A Mutex+ConditionVariable acts as a one-shot latch between threads.
# If VM1 fails before reaching the WG step the latch is still released so VM2 doesn't hang.
# vllm_preset is accepted but ignored — each VM uses its own TOML default preset.
- def run_create_both(replace:, dry_run:, install_vllm:, install_ollama:, vllm_preset: nil) # rubocop:disable Lint/UnusedMethodArgument
+ def run_create_both(replace:, dry_run:, install_vllm:, install_ollama:, vllm_preset: nil, flavor_name: nil) # rubocop:disable Lint/UnusedMethodArgument
vm1_loader, vm2_loader = pair_config_loaders
vm1_config = vm1_loader.config
vm2_config = vm2_loader.config
@@ -323,7 +332,8 @@ module HyperstackVM
errors = {}
errors_mutex = Mutex.new
create_opts = { replace: replace, dry_run: dry_run,
- install_vllm: install_vllm, install_ollama: install_ollama }
+ install_vllm: install_vllm, install_ollama: install_ollama,
+ flavor_name: flavor_name }
vm1_thread = Thread.new do
manager1.create(**create_opts)
diff --git a/lib/hyperstack/inference_tester.rb b/lib/hyperstack/inference_tester.rb
new file mode 100644
index 0000000..fa8f385
--- /dev/null
+++ b/lib/hyperstack/inference_tester.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require 'json'
+require 'net/http'
+
+module HyperstackVM
+ # End-to-end inference tests over WireGuard.
+ class InferenceTester
+ def initialize(config:, out:)
+ @config = config
+ @out = out
+ end
+
+ def test(state)
+ wg_ip = @config.wireguard_gateway_hostname
+ vllm_enabled = state_vllm_enabled?(state)
+ ollama_enabled = state_ollama_enabled?(state)
+ info "Running end-to-end inference tests via WireGuard (#{wg_ip})..."
+ test_vllm(wg_ip) if vllm_enabled
+ info " Ollama test: connect via SSH and run 'ollama list' to verify models." if ollama_enabled
+ info 'All inference tests passed.'
+ end
+
+ private
+
+ def test_vllm(wg_ip)
+ port = @config.ollama_port
+ info " Testing vLLM models list at http://#{wg_ip}:#{port}/v1/models..."
+ uri = URI("http://#{wg_ip}:#{port}/v1/models")
+ resp = Net::HTTP.get_response(uri)
+ raise Error, "vLLM /v1/models returned HTTP #{resp.code}" unless resp.code == '200'
+
+ models = JSON.parse(resp.body).fetch('data', []).map { |m| m['id'] }
+ raise Error, 'vLLM returned an empty model list' if models.empty?
+
+ model = models.first
+ info " Models loaded: #{models.join(', ')}"
+ info ' Testing vLLM inference...'
+ reply = chat(wg_ip, port, model, 'Say hello in five words.')
+ info " vLLM response: #{reply}"
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError => e
+ raise Error, "Cannot reach vLLM at #{wg_ip}:#{port} — is WireGuard (wg1) active? (#{e.message})"
+ end
+
+ def chat(host, port, model, prompt)
+ uri = URI("http://#{host}:#{port}/v1/chat/completions")
+ req = Net::HTTP::Post.new(uri)
+ req['Content-Type'] = 'application/json'
+ req['Authorization'] = 'Bearer EMPTY'
+ req.body = JSON.generate(
+ 'model' => model,
+ 'messages' => [{ 'role' => 'user', 'content' => prompt }],
+ 'max_tokens' => 500
+ )
+ resp = Net::HTTP.start(uri.host, uri.port, open_timeout: 10, read_timeout: 120) { |h| h.request(req) }
+ raise Error, "vLLM inference returned HTTP #{resp.code}" unless resp.code == '200'
+
+ JSON.parse(resp.body).dig('choices', 0, 'message', 'content').to_s.strip
+ end
+
+ def state_vllm_enabled?(state)
+ recorded = state&.dig('services', 'vllm_enabled')
+ return recorded unless recorded.nil?
+ return true if state&.key?('vllm_setup_at')
+ @config.vllm_install_enabled?
+ end
+
+ def state_ollama_enabled?(state)
+ recorded = state&.dig('services', 'ollama_enabled')
+ return recorded unless recorded.nil?
+ return true if state&.key?('ollama_installed_at') || state&.key?('ollama_setup_at')
+ @config.ollama_install_enabled?
+ end
+
+ def info(message)
+ @out.puts(message)
+ end
+ end
+end
diff --git a/lib/hyperstack/manager.rb b/lib/hyperstack/manager.rb
index 2813233..2150554 100644
--- a/lib/hyperstack/manager.rb
+++ b/lib/hyperstack/manager.rb
@@ -1,1014 +1,136 @@
# frozen_string_literal: true
-require 'fileutils'
-require 'json'
-require 'open3'
-require 'shellwords'
-require 'socket'
-require 'timeout'
+require_relative 'ssh_runner'
+require_relative 'vm_lifecycle'
+require_relative 'wireguard_setup'
+require_relative 'model_switcher'
+require_relative 'inference_tester'
+require_relative 'provisioning_orchestrator'
module HyperstackVM
+ # Thin facade that coordinates focused collaborators.
class Manager
- # wg_setup_pre: optional Proc called just before this VM's WireGuard setup step runs.
- # Used by create-both to block VM2 until VM1 has written the base wg1.conf.
- # wg_setup_post: optional Proc called after the WireGuard step completes (or is skipped).
- # Used by create-both to signal that VM1's base config is ready for VM2.
def initialize(config:, client:, state_store:, local_wireguard:, out: $stdout,
- wg_setup_pre: nil, wg_setup_post: nil)
+ local_wg_config_path: nil, wg_setup_pre: nil, wg_setup_post: nil)
@config = config
@client = client
@state_store = state_store
@local_wireguard = local_wireguard
@out = out
- @scripts = ProvisioningScripts.new(config: config)
- @provisioner = RemoteProvisioner.new(config: config, scripts: @scripts, out: out,
- ssh_command_runner: method(:run_ssh_command),
- ssh_stream_runner: method(:run_ssh_command_streaming))
- @wg_setup_pre = wg_setup_pre
+ @wg_setup_pre = wg_setup_pre
@wg_setup_post = wg_setup_post
+
+ @scripts = ProvisioningScripts.new(config: config)
+ @ssh_runner = SshRunner.new(config: config, out: out)
+ @vm_lifecycle = VmLifecycle.new(
+ config: config,
+ client: client,
+ state_store: state_store,
+ local_wireguard: local_wireguard,
+ out: out
+ )
+ @wireguard_setup = WireGuardSetup.new(
+ config: config,
+ ssh_runner: @ssh_runner,
+ local_wireguard: local_wireguard,
+ out: out,
+ wg_setup_pre: wg_setup_pre,
+ wg_setup_post: wg_setup_post
+ )
+ @provisioner = RemoteProvisioner.new(
+ config: config,
+ scripts: @scripts,
+ out: out,
+ ssh_command_runner: @ssh_runner.method(:run),
+ ssh_stream_runner: @ssh_runner.method(:run_streaming)
+ )
+ @inference_tester = InferenceTester.new(
+ config: config,
+ out: out
+ )
+ @orchestrator = ProvisioningOrchestrator.new(
+ config: config,
+ client: client,
+ state_store: state_store,
+ scripts: @scripts,
+ provisioner: @provisioner,
+ ssh_runner: @ssh_runner,
+ wireguard_setup: @wireguard_setup,
+ inference_tester: @inference_tester,
+ out: out
+ )
+ @model_switcher = ModelSwitcher.new(
+ config: config,
+ provisioner: @provisioner,
+ state_store: state_store,
+ out: out
+ )
end
def create(replace: false, dry_run: false, install_vllm: nil, install_ollama: nil,
- vllm_preset: nil)
- # CLI flags override config; nil means "use config default".
- @effective_vllm = install_vllm.nil? ? @config.vllm_install_enabled? : install_vllm
- @effective_ollama = install_ollama.nil? ? @config.ollama_install_enabled? : install_ollama
- # Validate preset name early so we fail before touching any remote state.
- @effective_vllm_preset = vllm_preset
- @config.vllm_preset(vllm_preset) if vllm_preset
- existing_state = @state_store.load
- if existing_state && existing_state['vm_id']
- if replace
- if dry_run
- info "DRY RUN: would delete tracked VM #{existing_state['vm_id']} before creating a replacement."
- else
- delete(vm_id: existing_state['vm_id'], preserve_state_on_failure: true)
- end
- elsif resumable_state?(existing_state)
- if dry_run
- print_resume_dry_run(existing_state)
- return
- end
+ flavor_name: nil, vllm_preset: nil)
+ raise Error, "DRY RUN is not supported." if dry_run
- info "Resuming tracked VM #{existing_state['vm_id']} provisioning..."
- continue_create(existing_state)
- return
- else
- raise Error,
- "State file #{@state_store.path} already tracks VM #{existing_state['vm_id']}. Use --replace or delete first."
+ if replace
+ existing = @state_store.load
+ if existing && existing['vm_id']
+ @vm_lifecycle.delete(vm_id: existing['vm_id'])
end
end
- resolved = resolve_dependencies
- vm_name = @config.generated_vm_name
- if dry_run
- info "Planning VM #{vm_name} in #{resolved[:environment]['name']} using #{@config.flavor_name}..."
- else
- info "Creating VM #{vm_name} in #{resolved[:environment]['name']} using #{@config.flavor_name}..."
- end
+ install_vllm = @config.vllm_install_enabled? if install_vllm.nil?
+ install_ollama = @config.ollama_install_enabled? if install_ollama.nil?
- payload = build_create_payload(vm_name, resolved)
- if dry_run
- print_create_dry_run(vm_name, resolved, payload)
- return
+ state = @vm_lifecycle.create(
+ flavor_name: flavor_name,
+ vllm_preset: vllm_preset,
+ install_vllm: install_vllm,
+ install_ollama: install_ollama
+ ) do |s|
+ @local_wireguard.show_local_wireguard(s['public_ip'])
end
- response = @client.create_vm(payload)
- instance = Array(response['instances']).first
- raise Error, 'Hyperstack create response did not include an instance ID.' unless instance && instance['id']
-
- state = {
- 'vm_id' => instance['id'],
- 'vm_name' => vm_name,
- 'environment_name' => resolved[:environment]['name'],
- 'region' => resolved[:environment]['region'],
- 'flavor_name' => resolved[:flavor]['name'],
- 'image_name' => resolved[:image]['name'],
- 'key_name' => resolved[:keypair]['name'],
- 'public_ip' => instance['floating_ip'],
- 'created_at' => Time.now.utc.iso8601
- }
- sync_service_mode_state(state)
- @state_store.save(state)
- continue_create(state)
+ @orchestrator.run(
+ state,
+ vllm_preset: vllm_preset,
+ install_vllm: install_vllm,
+ install_ollama: install_ollama
+ )
+ rescue Error => e
+ @state_store.save(state) if state
+ raise
end
def delete(vm_id: nil, preserve_state_on_failure: false, dry_run: false, skip_local_cleanup: false)
- state = @state_store.load
- target_vm_id = vm_id || state&.dig('vm_id')
- raise Error, "No VM ID provided and no state file found at #{@state_store.path}." if target_vm_id.nil?
-
- cleanup_local = !skip_local_cleanup && state && target_vm_id == state['vm_id']
-
- if dry_run
- print_delete_dry_run(target_vm_id, state, preserve_state_on_failure: preserve_state_on_failure)
- return
- end
-
- info "Deleting VM #{target_vm_id}..."
- @client.delete_vm(target_vm_id)
- wait_for_deletion(target_vm_id)
- if cleanup_local
- cleanup = cleanup_local_access(dry_run: false, hostnames: [@config.wireguard_gateway_hostname],
- allowed_ips: ["#{@config.wireguard_gateway_ip}/32"])
- report_local_cleanup(@out, cleanup, dry_run: false)
- end
- delete_ssh_known_hosts_file
- @state_store.delete unless preserve_state_on_failure
- info "VM #{target_vm_id} deleted."
- rescue Error => e
- raise if preserve_state_on_failure
-
- gone = e.message.include?('not_found') ||
- e.message.include?('does not exist') ||
- e.message.include?('does not exists') ||
- e.message.include?('404')
- @state_store.delete if gone
- raise
+ @vm_lifecycle.delete(
+ vm_id: vm_id,
+ preserve_state_on_failure: preserve_state_on_failure,
+ dry_run: dry_run,
+ skip_local_cleanup: skip_local_cleanup
+ )
end
def status(include_local_wireguard: true)
- state = @state_store.load
- if state.nil?
- info "No tracked VM state file at #{@state_store.path}."
- else
- begin
- vm = @client.get_vm(state['vm_id'])
- desired = desired_security_rules_for_state(state).map { |rule| normalize_rule(rule) }
- current = Array(vm['security_rules']).map { |rule| normalize_rule(rule) }
- missing_rules = desired - current
- vllm_enabled = state_vllm_enabled?(state)
- ollama_enabled = state_ollama_enabled?(state)
-
- info "Tracked VM: #{state['vm_id']} #{vm['name']}"
- info "Status: #{vm['status']} / #{vm['vm_state']}"
- info "Public IP: #{connect_host_for(vm) || 'none'}"
- info "Service mode: #{service_mode_summary(vllm_enabled: vllm_enabled, ollama_enabled: ollama_enabled)}"
- info "Active model: #{state['vllm_model'] || @config.vllm_model}" if vllm_enabled
- info "Missing firewall rules: #{missing_rules.empty? ? 'none' : missing_rules.size}"
- rescue Error => e
- warn "Unable to load VM #{state['vm_id']}: #{e.message}"
- end
- end
-
- print_local_wireguard_summary(state&.dig('public_ip')) if include_local_wireguard
- state&.dig('public_ip')
+ ip = @vm_lifecycle.status
+ @local_wireguard.show_local_wireguard(ip) if include_local_wireguard
+ ip
end
- def show_local_wireguard(expected_ips = nil)
- print_local_wireguard_summary(expected_ips)
+ def show_local_wireguard(expected_ips)
+ @vm_lifecycle.show_local_wireguard(expected_ips)
end
- # Lists configured model presets and marks the one currently running on the VM.
- def list_models
- presets = @config.vllm_preset_names
- state = @state_store.load
- current = state&.dig('vllm_model')
-
- if presets.empty?
- info 'No presets configured in [vllm.presets.*].'
- info "Active model: #{current || @config.vllm_model}"
- return
- end
-
- info 'Configured vLLM model presets:'
- presets.each do |name|
- p = @config.vllm_preset(name)
- active = p['model'] == current
- info " #{active ? '*' : ' '} #{name.ljust(24)} #{p['model']}"
- end
- info ''
- info ' (* = currently loaded on VM)' if current
- end
-
- # Switches the running VM to a different named model preset.
- # Stops the old container, then starts the new vLLM container in its place.
def switch_model(preset_name:, dry_run: false)
- preset = @config.vllm_preset(preset_name) # raises if unknown
- state = @state_store.load
-
- old_container = state&.dig('vllm_container_name') || @config.vllm_container_name
- new_container = preset['container_name']
- current_model = state&.dig('vllm_model')
-
- if dry_run
- info "DRY RUN: model switch to preset '#{preset_name}'"
- info " #{current_model || 'none'} → #{preset['model']}"
- info " container: #{old_container} → #{new_container}"
- trust_note = preset['trust_remote_code'] ? ', trust_remote_code: true' : ''
- parser_note = preset['tool_call_parser'].to_s.empty? ? 'none' : preset['tool_call_parser']
- extra_note = preset['extra_vllm_args']&.any? ? ", extra_args: #{preset['extra_vllm_args'].join(' ')}" : ''
- info " max_model_len: #{preset['max_model_len']}, tool_call_parser: #{parser_note}#{trust_note}#{extra_note}"
- return
- end
-
- raise Error, "No tracked VM. Run 'create' first." unless state&.dig('vm_id')
-
- host = state['public_ip']
- raise Error, 'No public IP in state file.' if host.nil? || host.empty?
-
- @provisioner.decommission_litellm(host)
-
- # Stop the old container only when it has a different name from the new one.
- @provisioner.stop_vllm_container(host, old_container) if old_container != new_container
-
- info "Starting vLLM with preset '#{preset_name}' (#{preset['model']})..."
- # Skip docker pull: image is already present; pulling on every switch risks a
- # surprise multi-GB download if the upstream image was updated.
- @provisioner.install_vllm(host, preset_config: preset, pull_image: false)
-
- state['vllm_model'] = preset['model']
- state['vllm_container_name'] = new_container
- state['vllm_preset'] = preset_name
- state['vllm_setup_at'] = Time.now.utc.iso8601
- state['services'] ||= {}
- state['services']['vllm_enabled'] = true
- state['services']['ollama_enabled'] = state_ollama_enabled?(state)
- @state_store.save(state)
-
- info "Model switched to '#{preset_name}' (#{preset['model']})."
- info "Run 'ruby hyperstack.rb test' to verify."
+ @model_switcher.switch(preset_name: preset_name, dry_run: dry_run)
end
- # Runs end-to-end inference tests against the active inference services over WireGuard.
- # Requires wg1 to be active and the VM to be fully provisioned.
def test
state = @state_store.load
- raise Error, "No tracked VM state file found at #{@state_store.path}." if state.nil?
-
- wg_ip = @config.wireguard_gateway_hostname
- vllm_enabled = state_vllm_enabled?(state)
- ollama_enabled = state_ollama_enabled?(state)
- info "Running end-to-end inference tests via WireGuard (#{wg_ip})..."
-
- test_vllm(wg_ip) if vllm_enabled
-
- info " Ollama test: connect via SSH and run 'ollama list' to verify models." if ollama_enabled
-
- info 'All inference tests passed.'
- end
-
- private
-
- def resumable_state?(state)
- state['vm_id'] && (
- state['bootstrapped_at'].nil? ||
- ollama_setup_needed?(state) ||
- vllm_setup_needed?(state) ||
- wireguard_setup_needed?(state)
- )
- end
-
- def continue_create(state)
- vm_id = state['vm_id']
- sync_service_mode_state(state)
-
- vm = wait_for_vm_ready(vm_id)
- ensure_security_rules(vm)
- vm = wait_for_connect_ip(vm_id)
- state['public_ip'] = connect_host_for(vm)
- state['security_rules'] = Array(vm['security_rules']).map { |rule| normalize_rule(rule) }
- @state_store.save(state)
-
- wait_for_ssh(state['public_ip'])
- @provisioner.decommission_litellm(state['public_ip'])
- if @config.guest_bootstrap_enabled? && state['bootstrapped_at'].nil?
- @provisioner.bootstrap_guest(state['public_ip'])
- state['bootstrapped_at'] = Time.now.utc.iso8601
- @state_store.save(state)
- end
-
- # Install Ollama binary and configure the service (fast), but defer
- # model pulls until after the WireGuard tunnel is up so that the user
- # can monitor progress over the tunnel.
- if effective_ollama? && state['ollama_installed_at'].nil?
- @provisioner.install_ollama_service(state['public_ip'])
- state['ollama_installed_at'] = Time.now.utc.iso8601
- @state_store.save(state)
- end
-
- # Call pre-hook before deciding whether WireGuard setup is needed; this allows a concurrent
- # sibling VM (e.g. VM2 in create-both) to block here until the primary VM (VM1) has
- # already written the base wg1.conf, which VM2's setup will then extend with its own peer.
- @wg_setup_pre&.call
- if wireguard_setup_needed?(state)
- run_wireguard_setup(state['public_ip'])
- state['wireguard_setup_at'] = Time.now.utc.iso8601
- @state_store.save(state)
- end
- # Always signal post-hook so that a waiting sibling VM is unblocked even when
- # WireGuard setup was not needed (e.g. already done on a resume).
- @wg_setup_post&.call
-
- # Pull and verify Ollama models after the tunnel is established.
- if ollama_setup_needed?(state)
- @provisioner.pull_ollama_models(state['public_ip'])
- state['ollama_setup_at'] = Time.now.utc.iso8601
- state['ollama_models_dir'] = @config.ollama_models_dir
- state['ollama_pulled_models'] = @scripts.desired_ollama_models
- @state_store.save(state)
- end
-
- # Set up vLLM after
- # the tunnel is up so that model-download progress is visible locally.
- if vllm_setup_needed?(state)
- preset_cfg = effective_vllm_preset_config
- @provisioner.setup_vllm_stack(state['public_ip'], preset_config: preset_cfg)
- state['vllm_setup_at'] = Time.now.utc.iso8601
- state['vllm_model'] = preset_cfg&.dig('model') || @config.vllm_model
- state['vllm_container_name'] = preset_cfg&.dig('container_name') || @config.vllm_container_name
- state['vllm_preset'] = @effective_vllm_preset
- @state_store.save(state)
- end
-
- vm = @client.get_vm(vm_id)
- state['security_rules'] = Array(vm['security_rules']).map { |rule| normalize_rule(rule) }
- state['status'] = vm['status']
- state['vm_state'] = vm['vm_state']
- state['provisioned_at'] = Time.now.utc.iso8601
- @state_store.save(state)
-
- info "VM ready: #{state['public_ip']} (id=#{state['vm_id']})"
- print_local_wireguard_summary(state['public_ip'])
- # Run end-to-end tests automatically so the human doesn't need a manual step.
- test
+ @inference_tester.test(state)
end
- def build_create_payload(vm_name, resolved)
- payload = {
- 'name' => vm_name,
- 'count' => 1,
- 'environment_name' => resolved[:environment]['name'],
- 'flavor_name' => resolved[:flavor]['name'],
- 'image_name' => resolved[:image]['name'],
- 'key_name' => resolved[:keypair]['name'],
- 'assign_floating_ip' => @config.assign_floating_ip?,
- 'create_bootable_volume' => @config.create_bootable_volume?,
- 'enable_port_randomization' => @config.enable_port_randomization?,
- 'security_rules' => desired_security_rules
- }
- payload['labels'] = @config.labels unless @config.labels.empty?
- payload['user_data'] = @config.user_data if @config.user_data
- payload
- end
-
- def resolve_dependencies
- environment = @client.list_environments.find { |item| item['name'] == @config.environment_name }
- raise Error, "Environment #{@config.environment_name.inspect} was not found in Hyperstack." unless environment
-
- flavor = @client.list_flavors.find do |item|
- item['name'] == @config.flavor_name && item['region_name'] == environment['region']
- end
- raise Error, "Flavor #{@config.flavor_name.inspect} is not available in #{environment['region']}." unless flavor
-
- if flavor['stock_available'] == false
- raise Error,
- "Flavor #{@config.flavor_name.inspect} exists in #{environment['region']} but is out of stock."
- end
-
- image = @client.list_images.find do |item|
- item['name'] == @config.image_name && item['region_name'] == environment['region']
- end
- raise Error, "Image #{@config.image_name.inspect} is not available in #{environment['region']}." unless image
-
- keypair = @client.list_keypairs.find do |item|
- item['name'] == @config.ssh_key_name && item.dig('environment', 'name') == environment['name']
- end
- unless keypair
- raise Error,
- "Keypair #{@config.ssh_key_name.inspect} was not found in environment #{environment['name']}."
- end
-
- {
- environment: environment,
- flavor: flavor,
- image: image,
- keypair: keypair
- }
- end
-
- def wait_for_vm_ready(vm_id)
- with_polling("VM #{vm_id} to become ready for firewall updates") do
- vm = @client.get_vm(vm_id)
- next nil if vm.nil?
-
- raise Error, "VM #{vm_id} entered failed state #{vm['status']} / #{vm['vm_state']}." if failed_vm?(vm)
-
- vm_ready_for_updates?(vm) ? vm : nil
- end
- end
-
- def wait_for_connect_ip(vm_id)
- ip_label = @config.assign_floating_ip? ? 'floating IP' : 'reachable IP'
- with_polling("VM #{vm_id} to receive a #{ip_label}") do
- vm = @client.get_vm(vm_id)
- raise Error, "VM #{vm_id} entered failed state #{vm['status']} / #{vm['vm_state']}." if failed_vm?(vm)
-
- connect_host_for(vm) ? vm : nil
- end
- end
-
- def wait_for_ssh(host)
- info "Waiting for SSH on #{host}:#{@config.ssh_port}..."
- with_polling("SSH on #{host}:#{@config.ssh_port}") do
- next nil unless tcp_open?(