From c693f37a6115f3567cd4fcff4c256a6d20dd6fac Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sat, 21 Mar 2026 09:46:58 +0200 Subject: moved --- snippets/hyperstack/.crush/logs/crush.log | 4 - snippets/hyperstack/.gitignore | 4 - snippets/hyperstack/.pi/settings.json | 8 - snippets/hyperstack/Gemfile | 3 - snippets/hyperstack/Gemfile.lock | 16 - snippets/hyperstack/README.md | 186 -- snippets/hyperstack/hyperstack-vm.toml | 204 --- snippets/hyperstack/hyperstack-vm1.toml | 185 -- snippets/hyperstack/hyperstack-vm2.toml | 182 -- snippets/hyperstack/hyperstack.rb | 2731 ----------------------------- snippets/hyperstack/pi-vm1 | 7 - snippets/hyperstack/pi-vm2 | 7 - snippets/hyperstack/vllm-setup.txt | 487 ----- snippets/hyperstack/wg1-setup.sh | 414 ----- 14 files changed, 4438 deletions(-) delete mode 100644 snippets/hyperstack/.crush/logs/crush.log delete mode 100644 snippets/hyperstack/.gitignore delete mode 100644 snippets/hyperstack/.pi/settings.json delete mode 100644 snippets/hyperstack/Gemfile delete mode 100644 snippets/hyperstack/Gemfile.lock delete mode 100644 snippets/hyperstack/README.md delete mode 100644 snippets/hyperstack/hyperstack-vm.toml delete mode 100644 snippets/hyperstack/hyperstack-vm1.toml delete mode 100644 snippets/hyperstack/hyperstack-vm2.toml delete mode 100755 snippets/hyperstack/hyperstack.rb delete mode 100755 snippets/hyperstack/pi-vm1 delete mode 100755 snippets/hyperstack/pi-vm2 delete mode 100644 snippets/hyperstack/vllm-setup.txt delete mode 100755 snippets/hyperstack/wg1-setup.sh (limited to 'snippets/hyperstack') diff --git a/snippets/hyperstack/.crush/logs/crush.log b/snippets/hyperstack/.crush/logs/crush.log deleted file mode 100644 index 7745db8..0000000 --- a/snippets/hyperstack/.crush/logs/crush.log +++ /dev/null @@ -1,4 +0,0 @@ -{"time":"2026-01-29T21:33:05.561515639+02:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/config.(*catwalkSync).Get.func1","file":"/home/paul/go/pkg/mod/github.com/charmbracelet/crush@v0.36.0/internal/config/catwalk.go","line":55},"msg":"Fetching providers from Catwalk"} -{"time":"2026-01-29T21:33:05.920268417+02:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/config.cache[...].Store","file":"/home/paul/go/pkg/mod/github.com/charmbracelet/crush@v0.36.0/internal/config/provider.go","line":213},"msg":"Saving provider data to disk","path":"/home/paul/.local/share/crush/providers.json"} -{"time":"2026-01-29T21:33:05.923610816+02:00","level":"WARN","source":{"function":"github.com/charmbracelet/crush/internal/config.(*Config).configureProviders-range1","file":"/home/paul/go/pkg/mod/github.com/charmbracelet/crush@v0.36.0/internal/config/load.go","line":295},"msg":"Provider is missing API key, this might be OK for local providers","provider":"ollama"} -{"time":"2026-01-29T21:33:05.923686216+02:00","level":"WARN","source":{"function":"github.com/charmbracelet/crush/internal/config.(*Config).configureProviders-range1","file":"/home/paul/go/pkg/mod/github.com/charmbracelet/crush@v0.36.0/internal/config/load.go","line":309},"msg":"Provider is missing API key, this might be OK for local providers","provider":"ollama"} diff --git a/snippets/hyperstack/.gitignore b/snippets/hyperstack/.gitignore deleted file mode 100644 index 132d791..0000000 --- a/snippets/hyperstack/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -.bundle/ -vendor/bundle/ -.hyperstack-vm-state.json -.hyperstack-vm*-state.json* diff --git a/snippets/hyperstack/.pi/settings.json b/snippets/hyperstack/.pi/settings.json deleted file mode 100644 index 23f5df6..0000000 --- a/snippets/hyperstack/.pi/settings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "defaultProvider": "hyperstack1", - "defaultModel": "cyankiwi/NVIDIA-Nemotron-3-Super-120B-A12B-AWQ-4bit", - "enabledModels": [ - "hyperstack1/cyankiwi/NVIDIA-Nemotron-3-Super-120B-A12B-AWQ-4bit", - "hyperstack2/bullpoint/Qwen3-Coder-Next-AWQ-4bit" - ] -} diff --git a/snippets/hyperstack/Gemfile b/snippets/hyperstack/Gemfile deleted file mode 100644 index a1bbd94..0000000 --- a/snippets/hyperstack/Gemfile +++ /dev/null @@ -1,3 +0,0 @@ -source "https://rubygems.org" - -gem "toml-rb", "~> 2.2" diff --git a/snippets/hyperstack/Gemfile.lock b/snippets/hyperstack/Gemfile.lock deleted file mode 100644 index 80e05d4..0000000 --- a/snippets/hyperstack/Gemfile.lock +++ /dev/null @@ -1,16 +0,0 @@ -GEM - remote: https://rubygems.org/ - specs: - citrus (3.0.2) - toml-rb (2.2.0) - citrus (~> 3.0, > 3.0) - -PLATFORMS - ruby - x86_64-linux - -DEPENDENCIES - toml-rb (~> 2.2) - -BUNDLED WITH - 2.6.9 diff --git a/snippets/hyperstack/README.md b/snippets/hyperstack/README.md deleted file mode 100644 index 730b310..0000000 --- a/snippets/hyperstack/README.md +++ /dev/null @@ -1,186 +0,0 @@ -# hyperstack - -Automates Hyperstack GPU VM lifecycle: create, bootstrap, WireGuard tunnel, vLLM inference, LiteLLM proxy. - -## Architecture - -``` -Claude Code (local) Hyperstack VM (A100 80GB) -┌─────────────────┐ ┌──────────────────────────────────┐ -│ claude CLI │── Anthropic API ─▶│ LiteLLM proxy (:4000) │ -│ │ /v1/messages │ Anthropic → OpenAI translation │ -│ │ via WireGuard │ │ │ -└─────────────────┘ │ ▼ │ - │ vLLM engine (:11434) │ -OpenCode (local) │ bullpoint/Qwen3-Coder-Next- │ -┌─────────────────┐ │ AWQ-4bit (45 GB, MoE 80B) │ -│ opencode │── OpenAI API ────▶│ FlashAttention v2 │ -│ │ /v1/chat/... │ prefix caching │ -└─────────────────┘ └──────────────────────────────────┘ -``` - -Both local clients connect over a WireGuard tunnel (`wg1`, subnet `192.168.3.0/24`). -The VM gets `192.168.3.1`; your local machine gets `192.168.3.2`. - -## Prerequisites - -- Hyperstack account with API key in `~/.hyperstack` -- SSH key registered in Hyperstack as `earth` (or change `ssh.hyperstack_key_name` in the TOML) -- Review `[network].allowed_ssh_cidrs` and `[network].allowed_wireguard_cidrs` in your TOML. - The secure default is `["auto"]`, which resolves your current public egress IP to `/32`. - Set explicit CIDRs or `HYPERSTACK_OPERATOR_CIDR` if you deploy from a different network. -- WireGuard setup script: `wg1-setup.sh` (present in this directory) -- Ruby with `toml-rb` gem: `bundle install` - -## Quickstart - -```bash -# Deploy VM, set up WireGuard + vLLM + LiteLLM (~10 min on first run) -ruby hyperstack.rb create - -# Verify everything is working -ruby hyperstack.rb test - -# Use Claude Code against the local vLLM -ANTHROPIC_BASE_URL=http://hyperstack.wg1:4000 \ -ANTHROPIC_API_KEY=sk-litellm-master \ -claude --model claude-opus-4-6-20260604 --dangerously-skip-permissions - -# Tear down -# Also removes the tracked local wg1 peer, hostname alias, and pinned SSH host key. -ruby hyperstack.rb delete -``` - -## Using Pi - -Bring both VMs up first: - -```bash -ruby hyperstack.rb create-both -``` - -Then start one Pi session per terminal: - -```bash -./pi-vm1 -./pi-vm2 -``` - -These wrappers `cd` into this repo before launching Pi, so the project-local -settings in `.pi/settings.json` still apply. - -## Using Claude Code with vLLM - -WireGuard (`wg1`) must be active before connecting. - -```bash -ANTHROPIC_BASE_URL=http://hyperstack.wg1:4000 \ -ANTHROPIC_API_KEY=sk-litellm-master \ -claude --model claude-opus-4-6-20260604 --dangerously-skip-permissions -``` - -If you see an **"Auth conflict"** warning, clear the saved claude.ai session first: - -```bash -claude /logout -``` - -**Fish shell alias** (add to `~/.config/fish/config.fish`): - -```fish -alias claude-local='ANTHROPIC_BASE_URL=http://hyperstack.wg1:4000 \ - ANTHROPIC_API_KEY=sk-litellm-master \ - claude --model claude-opus-4-6-20260604 --dangerously-skip-permissions' -``` - -**Available model aliases** — all map to the same vLLM model: - -| Alias | Use case | -|-------|----------| -| `claude-opus-4-6-20260604` | Recommended (most future-proof) | -| `claude-opus-4-20250514` | | -| `claude-sonnet-4-20250514` | | -| `claude-haiku-3-5-20241022` | | - -Add new Anthropic model IDs to `vllm.litellm_claude_model_names` in `hyperstack-vm.toml` as they are released. - -## Using OpenCode with vLLM - -OpenCode speaks OpenAI natively — connect directly to vLLM, no LiteLLM needed: - -```bash -OPENAI_BASE_URL=http://hyperstack.wg1:11434/v1 \ -OPENAI_API_KEY=EMPTY \ -opencode -``` - -Set the model name to `bullpoint/Qwen3-Coder-Next-AWQ-4bit` in your OpenCode config. - -## CLI reference - -``` -ruby hyperstack.rb [--config path] [options] - -Commands: - create Deploy a new VM and run full provisioning - delete Destroy the tracked VM - status Show VM and WireGuard status - test Run end-to-end inference tests (vLLM + LiteLLM) - -create options: - --replace Delete existing tracked VM before creating - --dry-run Print the plan without making changes - --vllm / --no-vllm Override config: enable/disable vLLM+LiteLLM setup - --ollama / --no-ollama Override config: enable/disable Ollama setup -``` - -## Configuration - -Edit `hyperstack-vm.toml` to change defaults. Key sections: - -| Section | Purpose | -|---------|---------| -| `[vm]` | Flavor, image, environment name | -| `[vllm]` | Model, container settings, LiteLLM key and Claude aliases | -| `[ollama]` | Ollama settings (disabled by default; set `install = true` to use instead) | -| `[network]` | Ports, WireGuard subnet, allowed CIDRs | -| `[wireguard]` | Auto-setup script path | - -`allowed_ssh_cidrs` and `allowed_wireguard_cidrs` accept either explicit CIDRs such as -`["203.0.113.4/32"]` or `["auto"]`. `auto` resolves the current public operator IP at runtime; -set `HYPERSTACK_OPERATOR_CIDR` to override that detection when needed. - -SSH host keys are pinned per state file in `.known_hosts`. `delete` and `--replace` -clear that trust file for intentional reprovisioning; unexpected host key changes now fail closed. - -## Monitoring vLLM - -```bash -# Live engine stats (throughput, KV cache, prefix cache hit rate) -ssh ubuntu@ 'docker logs -f vllm_qwen3 2>&1 | grep "Engine 000"' - -# Last 1 minute of stats -ssh ubuntu@ 'docker logs --since 1m vllm_qwen3 2>&1 | grep "Engine 000"' - -# GPU stats (every 5 s) -ssh ubuntu@ 'nvidia-smi --query-gpu=temperature.gpu,utilization.gpu,power.draw,memory.used --format=csv -l 5' - -# LiteLLM proxy log -ssh ubuntu@ 'sudo journalctl -fu litellm' -``` - -Healthy baseline (A100 80GB PCIe, qwen3-coder-next AWQ 4-bit): - -| Metric | Expected | -|--------|----------| -| Prefill throughput | 5,000–11,000 tok/s | -| Decode throughput | 40–99 tok/s | -| KV cache usage | 2–5% for typical sessions | -| Prefix cache hit (Claude Code) | 0% (expected — prompt prefix mutates each turn) | -| Prefix cache hit (OpenCode) | >50% after warm-up | - -## Switching models - -Stop the current container, start a new one with a different `--model`, then update `vllm.model` in `hyperstack-vm.toml` and re-run `ruby hyperstack.rb create` to reinstall LiteLLM with the updated config. - -See `vllm-setup.txt` for detailed vLLM and LiteLLM setup notes, VRAM sizing guide, and troubleshooting. diff --git a/snippets/hyperstack/hyperstack-vm.toml b/snippets/hyperstack/hyperstack-vm.toml deleted file mode 100644 index e82c97f..0000000 --- a/snippets/hyperstack/hyperstack-vm.toml +++ /dev/null @@ -1,204 +0,0 @@ -[auth] -api_key_file = "~/.hyperstack" - -[hyperstack] -base_url = "https://infrahub-api.nexgencloud.com/v1" - -[state] -file = ".hyperstack-vm-state.json" - -[vm] -name_prefix = "hyperstack" -hostname = "hyperstack" -environment_name = "snonux-ollama" - -# A100-80GB is the cost-first default for gpt-oss-120b inference. -# Switch this to n3-H100x1 if you want safer throughput and compatibility headroom. -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 = ["gpt-oss-120b", "wireguard"] - -[ssh] -username = "ubuntu" -private_key_path = "~/.ssh/id_rsa" -hyperstack_key_name = "earth" -port = 22 -connect_timeout_sec = 10 - -[network] -wireguard_udp_port = 56710 -wireguard_subnet = "192.168.3.0/24" -# Secure default: "auto" resolves your current public egress IP to /32 at runtime. -# Override with explicit CIDRs if you deploy from multiple networks or want broader access. -allowed_ssh_cidrs = ["auto"] -allowed_wireguard_cidrs = ["auto"] -# Port 11434 is shared by both Ollama and vLLM for firewall compatibility. -ollama_port = 11434 -# Port 4000: LiteLLM Anthropic-API proxy (used with vLLM). -litellm_port = 4000 - -[bootstrap] -enable_guest_bootstrap = true -install_wireguard = true -configure_ufw = true -configure_ollama_host = false - -[ollama] -# Disabled in favour of vLLM; set install = true to switch back to Ollama. -install = false -models_dir = "/ephemeral/ollama/models" -listen_host = "0.0.0.0:11434" -gpu_overhead_mb = 2000 -num_parallel = 1 -context_length = 32768 -pull_models = ["qwen3-coder-next", "qwen3-coder:30b", "gpt-oss:20b", "gpt-oss:120b", "nemotron-3-super"] - -# vLLM serves one model via Docker; LiteLLM translates Anthropic API → OpenAI. -# Use --vllm / --no-vllm CLI flags to override install at runtime. -[vllm] -install = true -model = "bullpoint/Qwen3-Coder-Next-AWQ-4bit" -# HuggingFace model cache on ephemeral NVMe (fast; survives reboots on most providers). -hug_cache_dir = "/ephemeral/hug" -container_name = "vllm_qwen3" -max_model_len = 262144 -gpu_memory_utilization = 0.92 -tensor_parallel_size = 1 -tool_call_parser = "qwen3_coder" -# LiteLLM maps each entry to the vLLM model; add new Anthropic model IDs here. -litellm_master_key = "sk-litellm-master" -litellm_claude_model_names = [ - "claude-sonnet-4-20250514", - "claude-opus-4-20250514", - "claude-opus-4-6-20260604", - "claude-haiku-3-5-20241022" -] - -# Named model presets for 'ruby hyperstack.rb model switch '. -# Each preset overrides the matching [vllm] field; unset fields fall back to [vllm] defaults. -# Switch examples: -# ruby hyperstack.rb model switch qwen3-coder-next # fast coding, 256k context -# ruby hyperstack.rb model switch nemotron-super # extended analysis, 131k context - -[vllm.presets.qwen3-coder-next] -model = "bullpoint/Qwen3-Coder-Next-AWQ-4bit" -container_name = "vllm_qwen3" -max_model_len = 262144 -gpu_memory_utilization = 0.92 -tensor_parallel_size = 1 -tool_call_parser = "qwen3_coder" - -# NVIDIA Nemotron-3-Super-120B-A12B AWQ 4-bit — hybrid Mamba+MoE (12B active / 120B total). -# ~60 GB weights on A100 80GB. Uses NoPE (no positional embeddings) so context can be set to -# 1M by just raising max_model_len; no YaRN needed. May OOM above 256K on A100 80GB. -# Requires trust_remote_code=true for the nemotron_h architecture. -# Note: cyankiwi AWQ has model_type="nemotron_nas" (underscore); vLLM keys on "nemotron-nas" -# (hyphen), so vLLM may not recognise it without trust_remote_code and latest vLLM. -# NVIDIA Nemotron-3-Super uses the same XML tool call format as Qwen3 XML: -# value -# qwen3_xml handles this format and is compatible with Nemotron's chat template. -[vllm.presets.nemotron-super] -model = "cyankiwi/NVIDIA-Nemotron-3-Super-120B-A12B-AWQ-4bit" -container_name = "vllm_nemotron_super" -max_model_len = 262144 -gpu_memory_utilization = 0.92 -tensor_parallel_size = 1 -tool_call_parser = "qwen3_xml" -trust_remote_code = true -# nemotron_v3 reasoning parser exposes tokens as reasoning_content in the API. -extra_vllm_args = ["--reasoning-parser", "nemotron_v3"] - -# OpenAI GPT-OSS 20B — ultra-fast MoE (3.6B active / 20B total, MXFP4), ~14 GB on A100. -# Native MXFP4 quantization; vLLM auto-detects it (no --quantization flag needed). -# With only 14 GB weights, most of the 80 GB is available for KV cache (64K+ context). -# tool_call_parser = "" disables --enable-auto-tool-choice: the llama3_json parser crashes -# on gpt-oss responses (vLLM 0.17.1 adds token_ids to responses, breaking the parser API). -[vllm.presets.gpt-oss-20b] -model = "openai/gpt-oss-20b" -container_name = "vllm_gpt_oss_20b" -max_model_len = 65536 -gpu_memory_utilization = 0.92 -tensor_parallel_size = 1 -tool_call_parser = "" - -# OpenAI GPT-OSS 120B — powerful MoE (5.1B active / 117B total, MXFP4), ~65 GB on A100. -# Hard architecture limit: max_position_embeddings=131072 in model config.json. -# 131072 is the absolute ceiling — exceeding it causes NaN or CUDA OOB errors. -# For sessions approaching this limit, start a fresh opencode conversation. -# tool_call_parser = "" disables --enable-auto-tool-choice (same reason as gpt-oss-20b). -[vllm.presets.gpt-oss-120b] -model = "openai/gpt-oss-120b" -container_name = "vllm_gpt_oss_120b" -max_model_len = 131072 -gpu_memory_utilization = 0.92 -tensor_parallel_size = 1 -tool_call_parser = "" - -# Qwen2.5-Coder-32B-Instruct AWQ — best-in-class open coding model at 32B, ~18 GB on A100. -# Official Qwen AWQ release; max_position_embeddings=32768 per model config.json. -[vllm.presets.qwen25-coder-32b] -model = "Qwen/Qwen2.5-Coder-32B-Instruct-AWQ" -container_name = "vllm_qwen25_coder32b" -max_model_len = 32768 -gpu_memory_utilization = 0.92 -tensor_parallel_size = 1 -tool_call_parser = "hermes" - -# Qwen3-Coder-30B-A3B AWQ — Qwen3 generation coding MoE (3B active / 30B total), ~18 GB. -# Note: model card warns of significant quality loss at 4-bit for this MoE architecture. -[vllm.presets.qwen3-coder-30b] -model = "QuantTrio/Qwen3-Coder-30B-A3B-Instruct-AWQ" -container_name = "vllm_qwen3_coder30b" -max_model_len = 65536 -gpu_memory_utilization = 0.92 -tensor_parallel_size = 1 -tool_call_parser = "qwen3_coder" - -# DeepSeek-R1-Distill-Qwen-32B AWQ — R1 reasoning distillation of Qwen 32B, ~18 GB on A100. -# Generates reasoning tokens; --reasoning-parser deepseek_r1 exposes them in the API. -# tool_call_parser="" disables tool calling (reasoning models don't support it reliably). -[vllm.presets.deepseek-r1-32b] -model = "casperhansen/deepseek-r1-distill-qwen-32b-awq" -container_name = "vllm_deepseek_r1_32b" -max_model_len = 32768 -gpu_memory_utilization = 0.92 -tensor_parallel_size = 1 -tool_call_parser = "" -extra_vllm_args = ["--reasoning-parser", "deepseek_r1"] - -# Qwen3-32B AWQ — dense 32B reasoning model with extended context, ~18 GB on A100. -# Native thinking mode; --reasoning-parser deepseek_r1 is compatible with Qwen3 thinking format. -# tool_call_parser="" disables tool calling (reasoning models don't support it reliably). -[vllm.presets.qwen3-32b] -model = "Qwen/Qwen3-32B-AWQ" -container_name = "vllm_qwen3_32b" -max_model_len = 32768 -gpu_memory_utilization = 0.92 -tensor_parallel_size = 1 -tool_call_parser = "" -extra_vllm_args = ["--reasoning-parser", "deepseek_r1"] - -# Devstral-Small-2507 AWQ — Mistral's coding agent model (~15 GB on A100). -# Uses HF safetensors weights but Mistral tokenizer (tekken.json) and config (params.json). -# --load_format mistral is NOT used: AWQ weights are in standard HF safetensors format. -# --tokenizer_mode mistral and --config_format mistral handle the Mistral-native files. -[vllm.presets.devstral] -model = "cyankiwi/Devstral-Small-2507-AWQ-4bit" -container_name = "vllm_devstral" -max_model_len = 32768 -gpu_memory_utilization = 0.92 -tensor_parallel_size = 1 -tool_call_parser = "mistral" -extra_vllm_args = ["--tokenizer_mode", "mistral", "--config_format", "mistral"] - -[wireguard] -auto_setup = true -setup_script = "./wg1-setup.sh" - -[local_client] -check_wg1_service = true -interface_name = "wg1" -config_path = "/etc/wireguard/wg1.conf" diff --git a/snippets/hyperstack/hyperstack-vm1.toml b/snippets/hyperstack/hyperstack-vm1.toml deleted file mode 100644 index 1b116bd..0000000 --- a/snippets/hyperstack/hyperstack-vm1.toml +++ /dev/null @@ -1,185 +0,0 @@ -[auth] -api_key_file = "~/.hyperstack" - -[hyperstack] -base_url = "https://infrahub-api.nexgencloud.com/v1" - -[state] -# Separate state file for VM1 so vm1 and vm2 can be managed independently. -file = ".hyperstack-vm1-state.json" - -[vm] -name_prefix = "hyperstack1" -hostname = "hyperstack1" -environment_name = "snonux-ollama" - -# A100-80GB is the cost-first default for nemotron-3-super inference. -# Switch this to n3-H100x1 if you want safer throughput and compatibility headroom. -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 = ["nemotron-3-super", "wireguard"] - -[ssh] -username = "ubuntu" -private_key_path = "~/.ssh/id_rsa" -hyperstack_key_name = "earth" -port = 22 -connect_timeout_sec = 10 - -[network] -wireguard_udp_port = 56710 -wireguard_subnet = "192.168.3.0/24" -# VM1 gets the first server-side WireGuard IP (gateway address + 0). -# earth (client) is 192.168.3.2; VM1 is 192.168.3.1; VM2 is 192.168.3.3. -wireguard_server_ip = "192.168.3.1" -# Secure default: "auto" resolves your current public egress IP to /32 at runtime. -# Override with explicit CIDRs if you deploy from multiple networks or want broader access. -allowed_ssh_cidrs = ["auto"] -allowed_wireguard_cidrs = ["auto"] -# Port 11434 is shared by both Ollama and vLLM for firewall compatibility. -ollama_port = 11434 -# Port 4000: LiteLLM Anthropic-API proxy (used with vLLM). -litellm_port = 4000 - -[bootstrap] -enable_guest_bootstrap = true -install_wireguard = true -configure_ufw = true -configure_ollama_host = false - -[ollama] -# Disabled in favour of vLLM; set install = true to switch back to Ollama. -install = false -models_dir = "/ephemeral/ollama/models" -listen_host = "0.0.0.0:11434" -gpu_overhead_mb = 2000 -num_parallel = 1 -context_length = 32768 -pull_models = ["nemotron-3-super"] - -# vLLM serves one model via Docker; LiteLLM translates Anthropic API → OpenAI. -# VM1 defaults to nemotron-3-super; use 'model switch' to load any other preset. -[vllm] -install = true -model = "cyankiwi/NVIDIA-Nemotron-3-Super-120B-A12B-AWQ-4bit" -# HuggingFace model cache on ephemeral NVMe (fast; survives reboots on most providers). -hug_cache_dir = "/ephemeral/hug" -container_name = "vllm_nemotron_super" -max_model_len = 262144 -gpu_memory_utilization = 0.92 -tensor_parallel_size = 1 -# NVIDIA Nemotron-3-Super uses the same XML tool call format as Qwen3 XML. -tool_call_parser = "qwen3_xml" -trust_remote_code = true -extra_vllm_args = ["--reasoning-parser", "nemotron_v3"] -# LiteLLM maps each entry to the vLLM model; add new Anthropic model IDs here. -litellm_master_key = "sk-litellm-master" -litellm_claude_model_names = [ - "claude-sonnet-4-20250514", - "claude-opus-4-20250514", - "claude-opus-4-6-20260604", - "claude-haiku-3-5-20241022" -] - -# Named model presets for 'ruby hyperstack.rb --config hyperstack-vm1.toml model switch '. -# Each preset overrides the matching [vllm] field; unset fields fall back to [vllm] defaults. - -[vllm.presets.qwen3-coder-next] -model = "bullpoint/Qwen3-Coder-Next-AWQ-4bit" -container_name = "vllm_qwen3" -max_model_len = 262144 -gpu_memory_utilization = 0.92 -tensor_parallel_size = 1 -tool_call_parser = "qwen3_coder" - -# NVIDIA Nemotron-3-Super-120B-A12B AWQ 4-bit — hybrid Mamba+MoE (12B active / 120B total). -# ~60 GB weights on A100 80GB. Uses NoPE so context can be set to 1M; no YaRN needed. -# Requires trust_remote_code=true for the nemotron_h architecture. -[vllm.presets.nemotron-super] -model = "cyankiwi/NVIDIA-Nemotron-3-Super-120B-A12B-AWQ-4bit" -container_name = "vllm_nemotron_super" -max_model_len = 262144 -gpu_memory_utilization = 0.92 -tensor_parallel_size = 1 -tool_call_parser = "qwen3_xml" -trust_remote_code = true -extra_vllm_args = ["--reasoning-parser", "nemotron_v3"] - -# OpenAI GPT-OSS 20B — ultra-fast MoE (3.6B active / 20B total, MXFP4), ~14 GB on A100. -[vllm.presets.gpt-oss-20b] -model = "openai/gpt-oss-20b" -container_name = "vllm_gpt_oss_20b" -max_model_len = 65536 -gpu_memory_utilization = 0.92 -tensor_parallel_size = 1 -tool_call_parser = "" - -# OpenAI GPT-OSS 120B — powerful MoE (5.1B active / 117B total, MXFP4), ~65 GB on A100. -# Hard architecture limit: max_position_embeddings=131072 in model config.json. -[vllm.presets.gpt-oss-120b] -model = "openai/gpt-oss-120b" -container_name = "vllm_gpt_oss_120b" -max_model_len = 131072 -gpu_memory_utilization = 0.92 -tensor_parallel_size = 1 -tool_call_parser = "" - -# Qwen2.5-Coder-32B-Instruct AWQ — best-in-class open coding model at 32B, ~18 GB on A100. -[vllm.presets.qwen25-coder-32b] -model = "Qwen/Qwen2.5-Coder-32B-Instruct-AWQ" -container_name = "vllm_qwen25_coder32b" -max_model_len = 32768 -gpu_memory_utilization = 0.92 -tensor_parallel_size = 1 -tool_call_parser = "hermes" - -# Qwen3-Coder-30B-A3B AWQ — Qwen3 generation coding MoE (3B active / 30B total), ~18 GB. -[vllm.presets.qwen3-coder-30b] -model = "QuantTrio/Qwen3-Coder-30B-A3B-Instruct-AWQ" -container_name = "vllm_qwen3_coder30b" -max_model_len = 65536 -gpu_memory_utilization = 0.92 -tensor_parallel_size = 1 -tool_call_parser = "qwen3_coder" - -# DeepSeek-R1-Distill-Qwen-32B AWQ — R1 reasoning distillation of Qwen 32B, ~18 GB on A100. -[vllm.presets.deepseek-r1-32b] -model = "casperhansen/deepseek-r1-distill-qwen-32b-awq" -container_name = "vllm_deepseek_r1_32b" -max_model_len = 32768 -gpu_memory_utilization = 0.92 -tensor_parallel_size = 1 -tool_call_parser = "" -extra_vllm_args = ["--reasoning-parser", "deepseek_r1"] - -# Qwen3-32B AWQ — dense 32B reasoning model with extended context, ~18 GB on A100. -[vllm.presets.qwen3-32b] -model = "Qwen/Qwen3-32B-AWQ" -container_name = "vllm_qwen3_32b" -max_model_len = 32768 -gpu_memory_utilization = 0.92 -tensor_parallel_size = 1 -tool_call_parser = "" -extra_vllm_args = ["--reasoning-parser", "deepseek_r1"] - -# Devstral-Small-2507 AWQ — Mistral's coding agent model (~15 GB on A100). -[vllm.presets.devstral] -model = "cyankiwi/Devstral-Small-2507-AWQ-4bit" -container_name = "vllm_devstral" -max_model_len = 32768 -gpu_memory_utilization = 0.92 -tensor_parallel_size = 1 -tool_call_parser = "mistral" -extra_vllm_args = ["--tokenizer_mode", "mistral", "--config_format", "mistral"] - -[wireguard] -auto_setup = true -setup_script = "./wg1-setup.sh" - -[local_client] -check_wg1_service = true -interface_name = "wg1" -config_path = "/etc/wireguard/wg1.conf" diff --git a/snippets/hyperstack/hyperstack-vm2.toml b/snippets/hyperstack/hyperstack-vm2.toml deleted file mode 100644 index e8e9b00..0000000 --- a/snippets/hyperstack/hyperstack-vm2.toml +++ /dev/null @@ -1,182 +0,0 @@ -[auth] -api_key_file = "~/.hyperstack" - -[hyperstack] -base_url = "https://infrahub-api.nexgencloud.com/v1" - -[state] -# Separate state file for VM2 so vm1 and vm2 can be managed independently. -file = ".hyperstack-vm2-state.json" - -[vm] -name_prefix = "hyperstack2" -hostname = "hyperstack2" -environment_name = "snonux-ollama" - -# A100-80GB is the cost-first default for qwen3-coder-next inference. -# Switch this to n3-H100x1 if you want safer throughput and compatibility headroom. -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 = ["qwen3-coder-next", "wireguard"] - -[ssh] -username = "ubuntu" -private_key_path = "~/.ssh/id_rsa" -hyperstack_key_name = "earth" -port = 22 -connect_timeout_sec = 10 - -[network] -wireguard_udp_port = 56710 -wireguard_subnet = "192.168.3.0/24" -# VM2 gets the third server-side WireGuard IP (skipping .2 which is the earth client). -# earth (client) is 192.168.3.2; VM1 is 192.168.3.1; VM2 is 192.168.3.3. -wireguard_server_ip = "192.168.3.3" -# Secure default: "auto" resolves your current public egress IP to /32 at runtime. -# Override with explicit CIDRs if you deploy from multiple networks or want broader access. -allowed_ssh_cidrs = ["auto"] -allowed_wireguard_cidrs = ["auto"] -# Port 11434 is shared by both Ollama and vLLM for firewall compatibility. -ollama_port = 11434 -# Port 4000: LiteLLM Anthropic-API proxy (used with vLLM). -litellm_port = 4000 - -[bootstrap] -enable_guest_bootstrap = true -install_wireguard = true -configure_ufw = true -configure_ollama_host = false - -[ollama] -# Disabled in favour of vLLM; set install = true to switch back to Ollama. -install = false -models_dir = "/ephemeral/ollama/models" -listen_host = "0.0.0.0:11434" -gpu_overhead_mb = 2000 -num_parallel = 1 -context_length = 32768 -pull_models = ["qwen3-coder-next"] - -# vLLM serves one model via Docker; LiteLLM translates Anthropic API → OpenAI. -# VM2 defaults to qwen3-coder-next; use 'model switch' to load any other preset. -[vllm] -install = true -model = "bullpoint/Qwen3-Coder-Next-AWQ-4bit" -# HuggingFace model cache on ephemeral NVMe (fast; survives reboots on most providers). -hug_cache_dir = "/ephemeral/hug" -container_name = "vllm_qwen3" -max_model_len = 262144 -gpu_memory_utilization = 0.92 -tensor_parallel_size = 1 -tool_call_parser = "qwen3_coder" -# LiteLLM maps each entry to the vLLM model; add new Anthropic model IDs here. -litellm_master_key = "sk-litellm-master" -litellm_claude_model_names = [ - "claude-sonnet-4-20250514", - "claude-opus-4-20250514", - "claude-opus-4-6-20260604", - "claude-haiku-3-5-20241022" -] - -# Named model presets for 'ruby hyperstack.rb --config hyperstack-vm2.toml model switch '. -# Each preset overrides the matching [vllm] field; unset fields fall back to [vllm] defaults. - -[vllm.presets.qwen3-coder-next] -model = "bullpoint/Qwen3-Coder-Next-AWQ-4bit" -container_name = "vllm_qwen3" -max_model_len = 262144 -gpu_memory_utilization = 0.92 -tensor_parallel_size = 1 -tool_call_parser = "qwen3_coder" - -# NVIDIA Nemotron-3-Super-120B-A12B AWQ 4-bit — hybrid Mamba+MoE (12B active / 120B total). -# ~60 GB weights on A100 80GB. Uses NoPE so context can be set to 1M; no YaRN needed. -# Requires trust_remote_code=true for the nemotron_h architecture. -[vllm.presets.nemotron-super] -model = "cyankiwi/NVIDIA-Nemotron-3-Super-120B-A12B-AWQ-4bit" -container_name = "vllm_nemotron_super" -max_model_len = 262144 -gpu_memory_utilization = 0.92 -tensor_parallel_size = 1 -tool_call_parser = "qwen3_xml" -trust_remote_code = true -extra_vllm_args = ["--reasoning-parser", "nemotron_v3"] - -# OpenAI GPT-OSS 20B — ultra-fast MoE (3.6B active / 20B total, MXFP4), ~14 GB on A100. -[vllm.presets.gpt-oss-20b] -model = "openai/gpt-oss-20b" -container_name = "vllm_gpt_oss_20b" -max_model_len = 65536 -gpu_memory_utilization = 0.92 -tensor_parallel_size = 1 -tool_call_parser = "" - -# OpenAI GPT-OSS 120B — powerful MoE (5.1B active / 117B total, MXFP4), ~65 GB on A100. -# Hard architecture limit: max_position_embeddings=131072 in model config.json. -[vllm.presets.gpt-oss-120b] -model = "openai/gpt-oss-120b" -container_name = "vllm_gpt_oss_120b" -max_model_len = 131072 -gpu_memory_utilization = 0.92 -tensor_parallel_size = 1 -tool_call_parser = "" - -# Qwen2.5-Coder-32B-Instruct AWQ — best-in-class open coding model at 32B, ~18 GB on A100. -[vllm.presets.qwen25-coder-32b] -model = "Qwen/Qwen2.5-Coder-32B-Instruct-AWQ" -container_name = "vllm_qwen25_coder32b" -max_model_len = 32768 -gpu_memory_utilization = 0.92 -tensor_parallel_size = 1 -tool_call_parser = "hermes" - -# Qwen3-Coder-30B-A3B AWQ — Qwen3 generation coding MoE (3B active / 30B total), ~18 GB. -[vllm.presets.qwen3-coder-30b] -model = "QuantTrio/Qwen3-Coder-30B-A3B-Instruct-AWQ" -container_name = "vllm_qwen3_coder30b" -max_model_len = 65536 -gpu_memory_utilization = 0.92 -tensor_parallel_size = 1 -tool_call_parser = "qwen3_coder" - -# DeepSeek-R1-Distill-Qwen-32B AWQ — R1 reasoning distillation of Qwen 32B, ~18 GB on A100. -[vllm.presets.deepseek-r1-32b] -model = "casperhansen/deepseek-r1-distill-qwen-32b-awq" -container_name = "vllm_deepseek_r1_32b" -max_model_len = 32768 -gpu_memory_utilization = 0.92 -tensor_parallel_size = 1 -tool_call_parser = "" -extra_vllm_args = ["--reasoning-parser", "deepseek_r1"] - -# Qwen3-32B AWQ — dense 32B reasoning model with extended context, ~18 GB on A100. -[vllm.presets.qwen3-32b] -model = "Qwen/Qwen3-32B-AWQ" -container_name = "vllm_qwen3_32b" -max_model_len = 32768 -gpu_memory_utilization = 0.92 -tensor_parallel_size = 1 -tool_call_parser = "" -extra_vllm_args = ["--reasoning-parser", "deepseek_r1"] - -# Devstral-Small-2507 AWQ — Mistral's coding agent model (~15 GB on A100). -[vllm.presets.devstral] -model = "cyankiwi/Devstral-Small-2507-AWQ-4bit" -container_name = "vllm_devstral" -max_model_len = 32768 -gpu_memory_utilization = 0.92 -tensor_parallel_size = 1 -tool_call_parser = "mistral" -extra_vllm_args = ["--tokenizer_mode", "mistral", "--config_format", "mistral"] - -[wireguard] -auto_setup = true -setup_script = "./wg1-setup.sh" - -[local_client] -check_wg1_service = true -interface_name = "wg1" -config_path = "/etc/wireguard/wg1.conf" diff --git a/snippets/hyperstack/hyperstack.rb b/snippets/hyperstack/hyperstack.rb deleted file mode 100755 index 7cd817d..0000000 --- a/snippets/hyperstack/hyperstack.rb +++ /dev/null @@ -1,2731 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -begin - require 'bundler/setup' -rescue LoadError, Gem::GemNotFoundException, Gem::LoadError, Errno::ENOENT - nil -end - -require 'json' -require 'fileutils' -require 'net/http' -require 'open3' -require 'optparse' -require 'ipaddr' -require 'shellwords' -require 'socket' -require 'time' -require 'timeout' - -begin - require 'toml-rb' -rescue LoadError - warn "Missing dependency: toml-rb. Run `bundle install` in #{__dir__} first." - exit 2 -end - -module HyperstackVM - class Error < StandardError; end - - class ConfigLoader - attr_reader :path - - def self.load(path) - expanded = File.expand_path(path) - raise Error, "Config file not found: #{expanded}" unless File.exist?(expanded) - - raw = TomlRB.load_file(expanded) - new(raw, expanded) - rescue TomlRB::ParseError => e - raise Error, "Failed to parse TOML config #{expanded}: #{e.message}" - end - - def initialize(raw, path) - @path = path - @data = deep_merge(DEFAULTS, raw || {}) - validate! - end - - def config - Config.new(@data, @path) - end - - private - - DEFAULTS = { - 'auth' => { - 'api_key_file' => '~/.hyperstack' - }, - 'hyperstack' => { - 'base_url' => 'https://infrahub-api.nexgencloud.com/v1' - }, - 'state' => { - 'file' => '.hyperstack-vm-state.json' - }, - 'vm' => { - 'name_prefix' => 'hyperstack', - 'hostname' => 'hyperstack', - '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' => %w[gpt-oss-120b wireguard] - }, - 'ssh' => { - 'username' => 'ubuntu', - 'private_key_path' => '~/.ssh/id_rsa', - 'hyperstack_key_name' => 'earth', - 'port' => 22, - 'connect_timeout_sec' => 10 - }, - 'network' => { - 'wireguard_udp_port' => 56_710, - 'wireguard_subnet' => '192.168.3.0/24', - # Optional: explicit server-side WireGuard IP. When nil, derived as subnet + 1 (i.e. .1). - # Set to a different address (e.g. 192.168.3.3) for a second VM sharing the same wg1 tunnel. - 'wireguard_server_ip' => nil, - 'ollama_port' => 11_434, - 'litellm_port' => 4_000, - 'allowed_ssh_cidrs' => ['auto'], - 'allowed_wireguard_cidrs' => ['auto'] - }, - 'bootstrap' => { - 'enable_guest_bootstrap' => true, - 'install_wireguard' => true, - 'configure_ufw' => true, - 'configure_ollama_host' => false - }, - 'ollama' => { - 'install' => false, - 'models_dir' => '/ephemeral/ollama/models', - 'listen_host' => '0.0.0.0:11434', - 'gpu_overhead_mb' => 2000, - 'num_parallel' => 1, - 'context_length' => 32_768, - 'pull_models' => ['qwen3-coder:30b', 'gpt-oss:20b', 'gpt-oss:120b', 'nemotron-3-super'] - }, - 'vllm' => { - 'install' => true, - 'model' => 'bullpoint/Qwen3-Coder-Next-AWQ-4bit', - 'hug_cache_dir' => '/ephemeral/hug', - 'container_name' => 'vllm_qwen3', - 'max_model_len' => 262_144, - 'gpu_memory_utilization' => 0.92, - 'tensor_parallel_size' => 1, - 'tool_call_parser' => 'qwen3_coder', - 'litellm_claude_model_names' => %w[ - claude-sonnet-4-20250514 - claude-opus-4-20250514 - claude-opus-4-6-20260604 - claude-haiku-3-5-20241022 - ], - 'litellm_master_key' => 'sk-litellm-master' - }, - 'wireguard' => { - 'auto_setup' => true, - 'setup_script' => './wg1-setup.sh' - }, - 'local_client' => { - 'check_wg1_service' => true, - 'interface_name' => 'wg1', - 'config_path' => '/etc/wireguard/wg1.conf' - } - }.freeze - - def validate! - %w[auth hyperstack state vm ssh network bootstrap ollama vllm wireguard local_client].each do |section| - raise Error, "Missing config section [#{section}]" unless @data.key?(section) - end - - %w[environment_name flavor_name image_name].each do |key| - raise Error, "Missing [vm].#{key} in config #{path}" if blank?(dig('vm', key)) - end - - if fetch('vm', 'hostname') && fetch('vm', 'hostname') !~ /\A[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\z/ - raise Error, - "Invalid [vm].hostname #{fetch('vm', - 'hostname').inspect}; use lowercase letters, digits, and hyphens only." - end - - %w[username hyperstack_key_name].each do |key| - raise Error, "Missing [ssh].#{key} in config #{path}" if blank?(dig('ssh', key)) - end - - ssh_cidrs = normalized_cidrs(fetch('network', 'allowed_ssh_cidrs')) - wireguard_cidrs = normalized_cidrs(fetch('network', 'allowed_wireguard_cidrs')) - - raise Error, missing_cidr_message('allowed_ssh_cidrs') if ssh_cidrs.empty? - raise Error, missing_cidr_message('allowed_wireguard_cidrs') if wireguard_cidrs.empty? - - [fetch('network', 'wireguard_subnet'), *ssh_cidrs, *wireguard_cidrs].each do |cidr| - next if cidr == 'auto' - - IPAddr.new(cidr) - rescue IPAddr::InvalidAddressError => e - raise Error, "Invalid CIDR #{cidr.inspect}: #{e.message}" - end - - server_ip = fetch('network', 'wireguard_server_ip') - if server_ip - # Validate that the explicit server WireGuard IP is within the configured subnet. - begin - subnet = IPAddr.new(fetch('network', 'wireguard_subnet')) - unless subnet.include?(IPAddr.new(server_ip)) - raise Error, - "wireguard_server_ip #{server_ip.inspect} is not in wireguard_subnet #{fetch('network', 'wireguard_subnet')}" - end - rescue IPAddr::InvalidAddressError => e - raise Error, "Invalid wireguard_server_ip #{server_ip.inspect}: #{e.message}" - end - end - end - - def fetch(section, key) - dig(section, key) - end - - def dig(*keys) - keys.reduce(@data) do |memo, key| - memo.is_a?(Hash) ? memo[key] : nil - end - end - - def blank?(value) - value.nil? || value.to_s.strip.empty? - end - - def truthy?(value) - value == true - end - - def normalized_cidrs(values) - Array(values).map { |value| value.to_s.strip }.reject(&:empty?) - end - - def missing_cidr_message(key) - "Missing [network].#{key} in config #{path}; set it to one or more CIDRs, or ['auto'] to restrict access to the current public operator IP." - end - - def deep_merge(left, right) - left.merge(right) do |_key, old_value, new_value| - if old_value.is_a?(Hash) && new_value.is_a?(Hash) - deep_merge(old_value, new_value) - else - new_value - end - end - end - end - - class Config - attr_reader :path - - def initialize(data, path = nil) - @data = data - @path = path - end - - def api_key - key_path = expand_path(fetch('auth', 'api_key_file')) - raise Error, "API key file not found: #{key_path}" unless File.exist?(key_path) - - token = File.readlines(key_path, chomp: true).find { |line| !line.strip.empty? }&.strip - raise Error, "API key file is empty: #{key_path}" if token.nil? || token.empty? - - token - rescue Errno::EACCES => e - raise Error, "Cannot read API key file #{key_path}: #{e.message}" - end - - def api_base_url - fetch('hyperstack', 'base_url') - end - - def state_file - expand_path(fetch('state', 'file')) - end - - def environment_name - fetch('vm', 'environment_name') - end - - def flavor_name - fetch('vm', 'flavor_name') - end - - def image_name - fetch('vm', 'image_name') - end - - def vm_name_prefix - fetch('vm', 'name_prefix') - end - - def generated_vm_name - "#{vm_name_prefix}-#{Time.now.utc.strftime('%Y%m%d%H%M%S')}" - end - - def vm_hostname - value = fetch('vm', 'hostname') - return nil if blank?(value) - - value.to_s.downcase - end - - def assign_floating_ip? - truthy?(fetch('vm', 'assign_floating_ip')) - end - - def create_bootable_volume? - truthy?(fetch('vm', 'create_bootable_volume')) - end - - def enable_port_randomization? - truthy?(fetch('vm', 'enable_port_randomization')) - end - - def labels - Array(fetch('vm', 'labels')).map(&:to_s) - end - - def user_data - custom = custom_user_data - return custom unless custom.nil? || custom.empty? - return nil if vm_hostname.nil? - - default_hostname_cloud_init - rescue Errno::ENOENT => e - raise Error, "User data file not found: #{e.message}" - rescue Errno::EACCES => e - raise Error, "Cannot read user data file: #{e.message}" - end - - def ssh_username - fetch('ssh', 'username') - end - - def ssh_private_key_path - expand_path(fetch('ssh', 'private_key_path')) - end - - def ssh_known_hosts_path - "#{state_file}.known_hosts" - end - - def ssh_key_name - fetch('ssh', 'hyperstack_key_name') - end - - def ssh_port - Integer(fetch('ssh', 'port')) - end - - def ssh_connect_timeout - Integer(fetch('ssh', 'connect_timeout_sec')) - end - - def wireguard_udp_port - Integer(fetch('network', 'wireguard_udp_port')) - end - - def wireguard_subnet - fetch('network', 'wireguard_subnet') - end - - def ollama_port - Integer(fetch('network', 'ollama_port')) - end - - def litellm_port - Integer(fetch('network', 'litellm_port')) - end - - # Returns the server-side WireGuard IP for this VM. - # Uses the explicitly configured address when set; otherwise derives it as subnet_base + 1. - # Example: 192.168.3.0/24 → 192.168.3.1 (default VM1); VM2 sets wireguard_server_ip=192.168.3.3. - def wireguard_gateway_ip - configured = fetch('network', 'wireguard_server_ip') - return configured.to_s if configured && !configured.to_s.strip.empty? - - # Fall back to first usable address in the subnet. - base = IPAddr.new(wireguard_subnet).to_s - parts = base.split('.').map(&:to_i) - parts[-1] += 1 - parts.join('.') - end - - # Returns the WireGuard hostname for this VM: e.g. hyperstack1.wg1 or hyperstack2.wg1. - # Used as the DNS name to reach the VM over the tunnel (must be in /etc/hosts on the client). - def wireguard_gateway_hostname - host = vm_hostname || 'hyperstack' - "#{host}.#{local_interface_name}" - end - - def allowed_ssh_cidrs - resolved_allowed_cidrs('allowed_ssh_cidrs') - end - - def allowed_wireguard_cidrs - resolved_allowed_cidrs('allowed_wireguard_cidrs') - end - - def guest_bootstrap_enabled? - truthy?(fetch('bootstrap', 'enable_guest_bootstrap')) - end - - def install_wireguard? - truthy?(fetch('bootstrap', 'install_wireguard')) - end - - def configure_ufw? - truthy?(fetch('bootstrap', 'configure_ufw')) - end - - def configure_ollama_host? - truthy?(fetch('bootstrap', 'configure_ollama_host')) - end - - def ollama_install_enabled? - truthy?(fetch('ollama', 'install')) - end - - def ollama_models_dir - fetch('ollama', 'models_dir') - end - - def ollama_listen_host - fetch('ollama', 'listen_host') - end - - def ollama_gpu_overhead_mb - Integer(fetch('ollama', 'gpu_overhead_mb')) - end - - def ollama_num_parallel - Integer(fetch('ollama', 'num_parallel')) - end - - def ollama_context_length - Integer(fetch('ollama', 'context_length')) - end - - def ollama_pull_models - Array(fetch('ollama', 'pull_models')).map(&:to_s) - end - - def vllm_install_enabled? - truthy?(fetch('vllm', 'install')) - end - - def vllm_model - fetch('vllm', 'model') - end - - def vllm_hug_cache_dir - fetch('vllm', 'hug_cache_dir') - end - - # Derived from hug_cache_dir: sibling directory for torch.compile artifacts. - # Persisted across container restarts so recompilation is skipped on warm switches. - def vllm_compile_cache_dir - File.join(File.dirname(fetch('vllm', 'hug_cache_dir')), 'vllm_cache') - end - - def vllm_container_name - fetch('vllm', 'container_name') - end - - def vllm_max_model_len - Integer(fetch('vllm', 'max_model_len')) - end - - def vllm_gpu_memory_utilization - Float(fetch('vllm', 'gpu_memory_utilization')) - end - - def vllm_tensor_parallel_size - Integer(fetch('vllm', 'tensor_parallel_size')) - end - - def vllm_tool_call_parser - fetch('vllm', 'tool_call_parser') - end - - def litellm_claude_model_names - Array(fetch('vllm', 'litellm_claude_model_names')).map(&:to_s) - end - - def litellm_master_key - fetch('vllm', 'litellm_master_key') - end - - # Whether to pass --trust-remote-code to vLLM for the default model. - # Required for architectures not yet in the vLLM upstream registry (e.g. nemotron_h). - def vllm_trust_remote_code - truthy?(fetch('vllm', 'trust_remote_code')) - end - - # Extra vLLM CLI flags for the default model (e.g. reasoning-parser args). - def vllm_extra_args - Array(fetch('vllm', 'extra_vllm_args')).map(&:to_s) - end - - def vllm_presets - Hash(dig('vllm', 'presets')).transform_keys(&:to_s) - end - - def vllm_preset_names - vllm_presets.keys - end - - def vllm_preset(name) - raw = vllm_presets[name.to_s] - unless raw - available = vllm_preset_names.empty? ? 'none configured' : vllm_preset_names.join(', ') - raise Error, "Unknown vLLM preset #{name.inspect}. Available: #{available}" - end - { - 'model' => raw['model'] || vllm_model, - 'container_name' => raw['container_name'] || vllm_container_name, - 'max_model_len' => Integer(raw['max_model_len'] || vllm_max_model_len), - 'gpu_memory_utilization' => Float(raw['gpu_memory_utilization'] || vllm_gpu_memory_utilization), - 'tensor_parallel_size' => Integer(raw['tensor_parallel_size'] || vllm_tensor_parallel_size), - 'tool_call_parser' => raw.key?('tool_call_parser') ? raw['tool_call_parser'] : vllm_tool_call_parser, - 'trust_remote_code' => raw.key?('trust_remote_code') ? raw['trust_remote_code'] : false, - 'extra_vllm_args' => raw.key?('extra_vllm_args') ? Array(raw['extra_vllm_args']) : [] - } - end - - def local_client_checks_enabled? - truthy?(fetch('local_client', 'check_wg1_service')) - end - - def local_interface_name - fetch('local_client', 'interface_name') - end - - def local_wg_config_path - fetch('local_client', 'config_path') - end - - def wireguard_auto_setup? - truthy?(fetch('wireguard', 'auto_setup')) - end - - def wireguard_setup_script - expand_path(fetch('wireguard', 'setup_script')) - end - - def desired_security_rules(include_ollama: ollama_install_enabled?, include_vllm: vllm_install_enabled?) - rules = [] - - allowed_ssh_cidrs.each do |cidr| - rules << firewall_rule('tcp', ssh_port, cidr) - end - - allowed_wireguard_cidrs.each do |cidr| - rules << firewall_rule('udp', wireguard_udp_port, cidr) - end - - rules << firewall_rule('tcp', ollama_port, wireguard_subnet) if include_ollama || include_vllm - rules << firewall_rule('tcp', litellm_port, wireguard_subnet) if include_vllm - rules.uniq - end - - private - - def fetch(section, key) - dig(section, key) - end - - def dig(*keys) - keys.reduce(@data) do |memo, key| - memo.is_a?(Hash) ? memo[key] : nil - end - end - - def blank?(value) - value.nil? || value.to_s.strip.empty? - end - - def truthy?(value) - value == true - end - - def resolved_allowed_cidrs(key) - values = Array(fetch('network', key)).map { |value| value.to_s.strip }.reject(&:empty?) - values.flat_map { |value| value == 'auto' ? [detected_operator_cidr] : [value] }.uniq - end - - def detected_operator_cidr - return @detected_operator_cidr if defined?(@detected_operator_cidr) - - configured = ENV['HYPERSTACK_OPERATOR_CIDR'].to_s.strip - @detected_operator_cidr = normalize_operator_cidr(configured) unless configured.empty? - return @detected_operator_cidr if defined?(@detected_operator_cidr) - - @detected_operator_cidr = detect_public_operator_cidr - end - - def normalize_operator_cidr(value) - ip = IPAddr.new(value) - suffix = ip.ipv4? ? 32 : 128 - value.include?('/') ? value : "#{ip}/#{suffix}" - rescue IPAddr::InvalidAddressError => e - raise Error, "Invalid HYPERSTACK_OPERATOR_CIDR #{value.inspect}: #{e.message}" - end - - def detect_public_operator_cidr - [ - 'https://api.ipify.org', - 'https://ifconfig.me/ip', - 'https://ipv4.icanhazip.com' - ].each do |url| - cidr = fetch_public_cidr(url) - return cidr if cidr - end - - source = path || 'the active config' - raise Error, - "Unable to detect the current public operator IP for [network].allowed_*_cidrs = ['auto']. Set HYPERSTACK_OPERATOR_CIDR or replace 'auto' with explicit CIDRs in #{source}." - end - - def fetch_public_cidr(url) - uri = URI(url) - response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 5, read_timeout: 5) do |http| - http.request(Net::HTTP::Get.new(uri)) - end - return nil unless response.is_a?(Net::HTTPSuccess) - - body = response.body.to_s.strip - return nil if body.empty? - - ip = IPAddr.new(body) - suffix = ip.ipv4? ? 32 : 128 - "#{ip}/#{suffix}" - rescue IPAddr::InvalidAddressError, SocketError, SystemCallError, Timeout::Error, Net::OpenTimeout, - Net::ReadTimeout, OpenSSL::SSL::SSLError - nil - end - - def custom_user_data - inline = dig('vm', 'user_data') - return inline unless inline.nil? || inline.empty? - - file = dig('vm', 'user_data_file') - return nil if file.nil? || file.empty? - - File.read(expand_path(file)) - end - - def default_hostname_cloud_init - <<~CLOUD_INIT - #cloud-config - preserve_hostname: false - hostname: #{vm_hostname} - CLOUD_INIT - end - - def expand_path(value) - return nil if value.nil? - - string = value.to_s - return File.expand_path(string) if string.start_with?('~') - return string if string.start_with?('/') - - File.expand_path(string, File.dirname(@path)) if @path - end - - def firewall_rule(protocol, port, cidr) - ip = IPAddr.new(cidr) - { - 'direction' => 'ingress', - 'ethertype' => ip.ipv4? ? 'IPv4' : 'IPv6', - 'protocol' => protocol, - 'port_range_min' => port, - 'port_range_max' => port, - 'remote_ip_prefix' => cidr - } - end - end - - class StateStore - def initialize(path) - @path = path - end - - attr_reader :path - - def load - return nil unless File.exist?(@path) - - JSON.parse(File.read(@path)) - rescue JSON::ParserError => e - raise Error, "Failed to parse state file #{@path}: #{e.message}" - end - - def save(payload) - temp_path = "#{@path}.tmp" - File.write(temp_path, JSON.pretty_generate(payload)) - File.rename(temp_path, @path) - end - - def delete - File.delete(@path) if File.exist?(@path) - end - end - - 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 - - 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 - - class LocalWireGuard - def initialize(interface_name:, config_path:) - @interface_name = interface_name - @config_path = config_path - end - - def status - endpoints = configured_endpoints - { - 'service_state' => service_state, - 'config_path' => @config_path, - 'endpoint' => endpoints.last, - 'endpoints' => endpoints, - 'config_readable' => !config_contents.nil? - } - end - - def remove_peers_by_allowed_ips(allowed_ips, dry_run: false) - targets = Array(allowed_ips).map(&:to_s).map(&:strip).reject(&:empty?).uniq - return [] if targets.empty? - - content = config_contents - raise Error, "Unable to read #{@config_path} for peer cleanup." if content.nil? - - updated, removed = prune_peer_blocks(content, targets) - return [] if removed.empty? - return removed if dry_run - - write_config(updated) - restart_service_if_active - @config_contents = updated - removed - end - - def remove_hostnames(hostnames, dry_run: false) - targets = Array(hostnames).map(&:to_s).map(&:strip).reject(&:empty?).uniq - return [] if targets.empty? - - content = hosts_contents - raise Error, 'Unable to read /etc/hosts for hostname cleanup.' if content.nil? - - updated, removed = prune_hosts_entries(content, targets) - return [] if removed.empty? - return removed if dry_run - - write_hosts(updated) - @hosts_contents = updated - removed - end - - private - - def service_state - stdout, _stderr, status = Open3.capture3('systemctl', 'is-active', "wg-quick@#{@interface_name}") - value = stdout.to_s.strip - return value unless value.empty? - return 'active' if status.success? - - 'unknown' - end - - def configured_endpoint - configured_endpoints.last - end - - def configured_endpoints - content = config_contents - return [] if content.nil? - - parse_wireguard_peers(content).filter_map { |peer| peer['Endpoint'] }.uniq - end - - def parse_wireguard_peers(content) - current_section = nil - current_peer = nil - peers = [] - - content.each_line do |line| - stripped = line.strip - next if stripped.empty? || stripped.start_with?('#') - - if stripped.start_with?('[') && stripped.end_with?(']') - peers << current_peer if current_section == 'Peer' && current_peer && !current_peer.empty? - current_section = stripped[1..-2] - current_peer = current_section == 'Peer' ? {} : nil - next - end - - key, value = stripped.split('=', 2).map { |part| part&.strip } - next unless current_section == 'Peer' && key && value - - current_peer[key] = value - end - - peers << current_peer if current_section == 'Peer' && current_peer && !current_peer.empty? - peers - end - - def prune_peer_blocks(content, allowed_ips) - kept = [] - removed = [] - - parse_wireguard_blocks(content).each do |block| - if block[:section] == 'Peer' && allowed_ips.include?(block[:values]['AllowedIPs'].to_s.strip) - removed << block[:values] - else - kept << block[:lines].join - end - end - - [kept.join, removed] - end - - def parse_wireguard_blocks(content) - blocks = [] - current_section = nil - current_lines = [] - - content.each_line do |line| - stripped = line.strip - if stripped.start_with?('[') && stripped.end_with?(']') - blocks << wireguard_block(current_section, current_lines) unless current_lines.empty? - current_section = stripped[1..-2] - current_lines = [line] - else - current_lines << line - end - end - - blocks << wireguard_block(current_section, current_lines) unless current_lines.empty? - blocks - end - - def wireguard_block(section, lines) - { - section: section, - lines: lines.dup, - values: parse_wireguard_section_values(section, lines) - } - end - - def parse_wireguard_section_values(section, lines) - return {} unless section == 'Peer' - - lines.each_with_object({}) do |line, values| - stripped = line.strip - next if stripped.empty? || stripped.start_with?('#') || stripped.start_with?('[') - - key, value = stripped.split('=', 2).map { |part| part&.strip } - values[key] = value if key && value - end - end - - def write_config(content) - File.write(@config_path, content) - rescue Errno::EACCES - _stdout, stderr, status = Open3.capture3('sudo', '-n', 'tee', @config_path, stdin_data: content) - raise Error, "Failed to update #{@config_path}: #{stderr.to_s.strip}" unless status.success? - - _stdout, stderr, status = Open3.capture3('sudo', '-n', 'chmod', '600', @config_path) - raise Error, "Failed to chmod #{@config_path}: #{stderr.to_s.strip}" unless status.success? - end - - def restart_service_if_active - return unless service_state == 'active' - - _stdout, stderr, status = Open3.capture3('sudo', '-n', 'systemctl', 'restart', "wg-quick@#{@interface_name}") - raise Error, "Failed to restart wg-quick@#{@interface_name}: #{stderr.to_s.strip}" unless status.success? - end - - def config_contents - return @config_contents if defined?(@config_contents) - - @config_contents = File.read(@config_path) - rescue Errno::EACCES, Errno::ENOENT - stdout, _stderr, status = Open3.capture3('sudo', '-n', 'cat', @config_path) - @config_contents = status.success? ? stdout : nil - end - - def hosts_contents - return @hosts_contents if defined?(@hosts_contents) - - @hosts_contents = File.read('/etc/hosts') - rescue Errno::EACCES, Errno::ENOENT - stdout, _stderr, status = Open3.capture3('sudo', '-n', 'cat', '/etc/hosts') - @hosts_contents = status.success? ? stdout : nil - end - - def prune_hosts_entries(content, hostnames) - removed = [] - updated = content.each_line.filter_map do |line| - rewritten, line_removed = prune_host_line(line, hostnames) - removed.concat(line_removed) - rewritten - end - [updated.join, removed.uniq] - end - - def prune_host_line(line, hostnames) - stripped = line.strip - return [line, []] if stripped.empty? || stripped.start_with?('#') - - body, comment = line.split('#', 2) - tokens = body.split(/\s+/) - return [line, []] if tokens.empty? - - ip = tokens.shift - removed = tokens & hostnames - return [line, []] if removed.empty? - - remaining = tokens - hostnames - return [nil, removed] if remaining.empty? - - rewritten = ([ip] + remaining).join("\t") - rewritten = "#{rewritten} # #{comment.strip}" if comment && !comment.strip.empty? - ["#{rewritten}\n", removed] - end - - def write_hosts(content) - File.write('/etc/hosts', content) - rescue Errno::EACCES - _stdout, stderr, status = Open3.capture3('sudo', '-n', 'tee', '/etc/hosts', stdin_data: content) - raise Error, "Failed to update /etc/hosts: #{stderr.to_s.strip}" unless status.success? - end - end - - # Thread-safe output wrapper that prepends a fixed prefix to each line. - # Used by create-both so