From 2aa71022ad2d9379cf38db522194f38c05115d26 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Mon, 25 May 2026 21:09:23 +0300 Subject: refactor(manager): move dry-run/resume logic into VmLifecycle; thin Manager facade - Manager is now a thin facade (~160 lines) that delegates VM lifecycle, provisioning, testing, and cleanup to focused collaborators. - Moved replace/resume/dry-run handling and presentation helpers from Manager into VmLifecycle where they belong. - Fixed operator-precedence bug: instance && instance['id']. - Fixed status fallback to return public_ip from state on API error. - Fixed LocalWireGuard delegation bug (removed @local_wireguard.show_local_wireguard call which did not exist; now routes through Manager#show_local_wireguard). - Removed dead code: unused ensure_security_rules and legacy_litellm from VmLifecycle. - ProvisioningOrchestrator: removed spurious show_local_wireguard call and added legacy_litellm_rules cleanup with proper warn_out helper. - Added missing require statements (json, fileutils, provisioning). - Removed dead attr_reader :config from InferenceTester. - Renamed shadowed variable in cleanup_local_access. --- lib/hyperstack/vm_lifecycle.rb | 150 +++++++++++++++++++++++++++++------------ 1 file changed, 108 insertions(+), 42 deletions(-) (limited to 'lib/hyperstack/vm_lifecycle.rb') diff --git a/lib/hyperstack/vm_lifecycle.rb b/lib/hyperstack/vm_lifecycle.rb index 972c896..cc52880 100644 --- a/lib/hyperstack/vm_lifecycle.rb +++ b/lib/hyperstack/vm_lifecycle.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +require 'json' +require_relative 'provisioning' + module HyperstackVM # Orchestrates the VM lifecycle from creation through deletion. class VmLifecycle @@ -9,27 +12,52 @@ module HyperstackVM @state_store = state_store @local_wireguard = local_wireguard @out = out + @scripts = ProvisioningScripts.new(config: config) end attr_reader :config, :client, :state_store - def create(flavor_name: nil, vllm_preset: nil, install_vllm: nil, install_ollama: nil, &block) + def create(replace: false, dry_run: false, flavor_name: nil, vllm_preset: nil, + install_vllm: nil, install_ollama: nil, &block) @effective_flavor_name = flavor_name.nil? ? @config.flavor_name : flavor_name @state_store.load if defined?(@state_store) # force load existing = @state_store.load if existing && existing['vm_id'] - raise Error, - "State file #{@state_store.path} already tracks VM #{existing['vm_id']}. Use --replace or delete first." + if replace + if dry_run + info "DRY RUN: would delete tracked VM #{existing['vm_id']} before creating a replacement." + show_local_wireguard([]) + return nil + else + delete(vm_id: existing['vm_id']) + end + elsif resumable_state?(existing) + if dry_run + print_resume_dry_run(existing, install_vllm: install_vllm, install_ollama: install_ollama, vllm_preset: vllm_preset) + return nil + end + info "Resuming tracked VM #{existing['vm_id']} provisioning..." + return existing + else + raise Error, + "State file #{@state_store.path} already tracks VM #{existing['vm_id']}. Use --replace or delete first." + end end resolved = resolve_dependencies vm_name = @config.generated_vm_name - info "Creating VM #{vm_name} in #{resolved[:environment]['name']} using #{@effective_flavor_name}..." + info (dry_run ? "Planning" : "Creating") + " VM #{vm_name} in #{resolved[:environment]['name']} using #{@effective_flavor_name}..." payload = build_payload(vm_name, resolved, install_vllm: install_vllm, install_ollama: install_ollama) + if dry_run + print_create_dry_run(vm_name, resolved, payload, install_vllm: install_vllm, install_ollama: install_ollama, vllm_preset: vllm_preset) + show_local_wireguard([]) + return nil + end + response = @client.create_vm(payload) instance = Array(response['instances']).first - raise Error, 'Hyperstack create response did not include an instance ID.' unless instance&&['id'] + raise Error, 'Hyperstack create response did not include an instance ID.' unless instance && instance['id'] state = build_state(vm_name, instance, resolved) sync_service_mode(state, install_vllm: install_vllm, install_ollama: install_ollama) @@ -87,20 +115,23 @@ module HyperstackVM info "Missing firewall rules: #{missing.empty? ? 'none' : missing.size}" rescue Error => e warn_out "Unable to load VM #{state['vm_id']}: #{e.message}" + return state&.dig('public_ip') end connect_host_for(vm) end - def resolve_dependencies + def resolve_dependencies(flavor_name: nil) + flavor_name = @effective_flavor_name if flavor_name.nil? && @effective_flavor_name + flavor_name = @config.flavor_name if flavor_name.nil? 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'] == @effective_flavor_name && item['region_name'] == environment['region'] + item['name'] == flavor_name && item['region_name'] == environment['region'] end - raise Error, "Flavor #{@effective_flavor_name.inspect} is not available in #{environment['region']}." unless flavor + raise Error, "Flavor #{flavor_name.inspect} is not available in #{environment['region']}." unless flavor if flavor['stock_available'] == false - raise Error, "Flavor #{@effective_flavor_name.inspect} exists in #{environment['region']} but is out of stock." + raise Error, "Flavor #{flavor_name.inspect} exists in #{environment['region']} but is out of stock." end image = @client.list_images.find do |item| @@ -146,29 +177,6 @@ module HyperstackVM end end - def ensure_security_rules(vm) - existing = Array(vm['security_rules']) - existing_norm = existing.map { |r| normalize_rule(r) } - desired = desired_rules.map { |r| normalize_rule(r) } - - (desired - existing_norm).each do |rule| - info "Adding Hyperstack firewall rule #{rule['protocol']} #{rule['remote_ip_prefix']} #{rule['port_range_min']}..." - @client.create_vm_rule(vm['id'], rule) - end - - legacy_litellm(existing).each do |rule| - rule_id = rule['id'] || rule['rule_id'] - unless rule_id - warn_out 'Found legacy Hyperstack firewall rule for port 4000, but the API payload has no rule id; remove it manually from the Hyperstack console.' - next - end - info "Removing legacy Hyperstack firewall rule #{rule['protocol']} #{rule['remote_ip_prefix']} #{rule['port_range_min']}..." - @client.delete_vm_rule(vm['id'], rule_id) - rescue Error => e - warn_out "Failed to remove legacy Hyperstack firewall rule #{rule_id}: #{e.message}" - end - end - def connect_host_for(vm) return vm['floating_ip'] if @config.assign_floating_ip? vm['floating_ip'] || vm['fixed_ip'] @@ -246,6 +254,74 @@ module HyperstackVM private + def resumable_state?(state) + state && state['vm_id'] && state['provisioned_at'].nil? + end + + def print_create_dry_run(vm_name, resolved, payload, install_vllm:, install_ollama:, vllm_preset:) + info 'DRY RUN: no VM or state file will be created.' + info "State file: #{@state_store.path}" + info "Resolved environment: #{resolved[:environment]['name']} (region #{resolved[:environment]['region']})" + info "Resolved flavor: #{format_flavor(resolved[:flavor])}" + info "Resolved image: #{resolved[:image]['name']}" + info "Resolved SSH keypair: #{resolved[:keypair]['name']}" + info "Planned VM name: #{vm_name}" + info "Allowed SSH CIDRs: #{@config.allowed_ssh_cidrs.join(', ')}" + info "Allowed WireGuard CIDRs: #{@config.allowed_wireguard_cidrs.join(', ')}" + info 'Create payload:' + @out.puts(JSON.pretty_generate(payload)) + if @config.guest_bootstrap_enabled? + info 'Guest bootstrap script:' + @out.puts(@scripts.guest_bootstrap_script) + else + info 'Guest bootstrap is disabled in config.' + end + if install_ollama + info "Ollama will be installed with models stored under #{@config.ollama_models_dir}" + models = @scripts.desired_ollama_models + info "Ollama models to pre-pull: #{models.join(', ')}" unless models.empty? + end + if install_vllm + preset_cfg = vllm_preset ? @config.vllm_preset(vllm_preset) : nil + vllm_m = preset_cfg&.dig('model') || @config.vllm_model + vllm_cname = preset_cfg&.dig('container_name') || @config.vllm_container_name + vllm_maxlen = preset_cfg&.dig('max_model_len') || @config.vllm_max_model_len + preset_note = vllm_preset ? " (preset: #{vllm_preset})" : '' + info "vLLM will be installed: #{vllm_m}#{preset_note}" + info " Container: #{vllm_cname}, port #{@config.ollama_port}, max_model_len #{vllm_maxlen}" + end + if @config.wireguard_auto_setup? + info "WireGuard auto-setup script: #{@config.wireguard_setup_script} " + end + end + + def print_resume_dry_run(state, install_vllm:, install_ollama:, vllm_preset:) + info "DRY RUN: would resume provisioning tracked VM #{state['vm_id']}." + begin + vm = @client.get_vm(state['vm_id']) + info "Tracked VM status: #{vm['status']} / #{vm['vm_state']}" + ip = vm['floating_ip'] || vm['fixed_ip'] + info "Tracked VM public IP: #{ip || 'none'}" + rescue Error => e + warn_out "Unable to inspect tracked VM #{state['vm_id']}: #{e.message}" + end + if @config.guest_bootstrap_enabled? && state['bootstrapped_at'].nil? + info 'Guest bootstrap script:' + @out.puts(@scripts.guest_bootstrap_script) + end + if install_ollama && state['ollama_installed_at'].nil? + info "Ollama would be installed with models stored under #{@config.ollama_models_dir}" + models = @scripts.desired_ollama_models + info "Ollama models to pre-pull: #{models.join(', ')}" unless models.empty? + end + if install_vllm && state['vllm_setup_at'].nil? + info "vLLM would be installed: #{state['vllm_model'] || @config.vllm_model}" + end + if @config.wireguard_auto_setup? && state['wireguard_setup_at'].nil? + info "WireGuard auto-setup script would run: #{@config.wireguard_setup_script} #{state['public_ip'] || ''}" + end + end + def build_payload(vm_name, resolved, install_vllm: nil, install_ollama: nil) payload = { 'name' => vm_name, @@ -306,16 +382,6 @@ module HyperstackVM parts.empty? ? 'All inference services disabled' : "#{parts.join(', ')} enabled" end - def legacy_litellm(rules) - Array(rules).select do |rule| - normalized = normalize_rule(rule) - normalized['protocol'] == 'tcp' && - normalized['port_range_min'] == 4000 && - normalized['port_range_max'] == 4000 && - normalized['remote_ip_prefix'] == @config.wireguard_subnet - end - end - def perform_local_cleanup(dry_run:) peers = @local_wireguard.remove_peers_by_allowed_ips( ["#{@config.wireguard_gateway_ip}/32"], dry_run: dry_run -- cgit v1.2.3