diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-24 12:49:57 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-24 12:49:57 +0300 |
| commit | 993b63cb32135bc8d45defd7d1549d83df200cad (patch) | |
| tree | 2be15d7481ec9b13af37e5fefca06027d1bf3a1b /lib/hyperstack | |
| parent | bed1f24770ca1995e38278d4804b84f957ddc028 (diff) | |
feat(cli): replace --config with --vm 1|2|both, remove create-both/delete-both
- Drop single-VM default hyperstack-vm.toml and @config_path/@config_explicit machinery
- Add global --vm flag (default: 1) mapping to hyperstack-vm1.toml and/or hyperstack-vm2.toml
- Fold create-both and delete-both into create/delete --vm both
- Teach status, watch, test, model to accept --vm (default: 1)
- Update help text and README/AGENTS/fish abbreviations accordingly
Diffstat (limited to 'lib/hyperstack')
| -rw-r--r-- | lib/hyperstack/cli.rb | 197 |
1 files changed, 105 insertions, 92 deletions
diff --git a/lib/hyperstack/cli.rb b/lib/hyperstack/cli.rb index 9be78b0..2669186 100644 --- a/lib/hyperstack/cli.rb +++ b/lib/hyperstack/cli.rb @@ -11,36 +11,31 @@ module HyperstackVM def initialize(argv) @argv = argv.dup - @config_path = File.join(REPO_ROOT, 'hyperstack-vm.toml') - @config_explicit = false + @vm = '1' end def show_help puts @global_parser puts puts 'Commands:' - puts ' create [--replace] [--dry-run] [--vllm|--no-vllm] [--ollama|--no-ollama] [--model PRESET]' - puts ' create-both [--replace] [--dry-run] [--vllm|--no-vllm] [--ollama|--no-ollama]' - puts ' Provision hyperstack-vm1.toml and hyperstack-vm2.toml concurrently.' - puts ' WireGuard setup is serialized: VM1 writes the base wg1.conf first,' - puts ' then VM2 adds its peer. Requires both TOML files next to the script.' - puts ' delete [--vm-id ID] [--dry-run]' - puts ' delete-both [--dry-run]' - puts ' Delete the VMs tracked by hyperstack-vm1.toml and hyperstack-vm2.toml.' + puts ' create [--replace] [--dry-run] [--vllm|--no-vllm] [--ollama|--no-ollama] [--model PRESET]' + puts ' delete [--vm-id ID] [--dry-run]' puts ' status' puts ' watch' - puts ' Poll all active VMs for vLLM and GPU stats every 60 s.' + puts ' Poll active VMs for vLLM and GPU stats every 60 s.' puts ' test' puts ' model list' puts ' model switch PRESET [--dry-run]' + puts + puts 'All commands accept --vm 1|2|both (default: 1).' end def run @global_parser = OptionParser.new do |opts| - opts.banner = 'Usage: ruby hyperstack.rb [--config path] <create|delete|status> [options]' - opts.on('--config PATH', "Path to TOML config (default: #{@config_path})") do |value| - @config_path = value - @config_explicit = true + opts.banner = 'Usage: ruby hyperstack.rb [--vm 1|2|both] <create|delete|status|watch|test|model> [options]' + opts.on('--vm 1|2|both', 'Target VM (default: 1)') do |value| + raise Error, "Invalid --vm value #{value.inspect}. Use 1, 2, or both." unless %w[1 2 both].include?(value) + @vm = value end opts.on('-h', '--help', 'Show help') do show_help @@ -55,79 +50,68 @@ module HyperstackVM exit 0 end - # create-both loads its own config files and does not use the default config path. - # Parse it before building the manager so we avoid loading the default config needlessly. - if command == 'create-both' - opts = parse_create_options(@argv, include_model_preset: false) - run_create_both(**opts) - return - end - - if command == 'delete-both' - opts = parse_delete_both_options(@argv) - run_delete_both(**opts) - return - end - - if command == 'status' - run_status - return - end - - if command == 'watch' - run_watch - return - end - - # All other commands operate on a single VM defined by the --config path. - config_loader = ConfigLoader.load(@config_path) - manager = build_manager(config_loader.config) - case command when 'create' - opts = parse_create_options(@argv) - manager.create(**opts) + if @vm == 'both' + opts = parse_create_options(@argv, include_model_preset: false) + run_create_both(**opts) + else + opts = parse_create_options(@argv) + build_manager_for_vm(@vm).create(**opts) + end when 'delete' - vm_id = nil - dry_run = false - parser = OptionParser.new do |opts| - opts.on('--vm-id ID', Integer, 'Delete a VM by ID instead of using the local state file') do |value| - vm_id = value + if @vm == 'both' + opts = parse_delete_options(@argv) + run_delete_both(**opts) + else + vm_id = nil + dry_run = false + parser = OptionParser.new do |opts| + opts.on('--vm-id ID', Integer, 'Delete a VM by ID instead of using the local state file') do |value| + vm_id = value + end + opts.on('--dry-run', 'Show which VM would be deleted without deleting it') { dry_run = true } end - opts.on('--dry-run', 'Show which VM would be deleted without deleting it') { dry_run = true } + parser.parse!(@argv) + build_manager_for_vm(@vm).delete(vm_id: vm_id, dry_run: dry_run) end - parser.parse!(@argv) - manager.delete(vm_id: vm_id, dry_run: dry_run) + when 'status' + run_status + when 'watch' + run_watch when 'test' - manager.test + run_test when 'model' - sub = @argv.shift - raise Error, 'Missing model subcommand. Use: model list | model switch PRESET [--dry-run]' if sub.nil? - - case sub - when 'list' - manager.list_models - when 'switch' - preset = @argv.shift - raise Error, 'Missing preset name. Usage: model switch PRESET [--dry-run]' if preset.nil? - - dry_run = false - OptionParser.new { |o| o.on('--dry-run') { dry_run = true } }.parse!(@argv) - manager.switch_model(preset_name: preset, dry_run: dry_run) - else - raise Error, "Unknown model subcommand #{sub.inspect}. Use list or switch." - end + run_model else raise Error, - "Unknown command #{command.inspect}. Use create, create-both, delete, delete-both, status, watch, test, or model." + "Unknown command #{command.inspect}. Use create, delete, status, watch, test, or model." end end private + def vm_config_path(vm) + File.join(REPO_ROOT, "hyperstack-vm#{vm}.toml") + end + + def build_manager_for_vm(vm) + loader = ConfigLoader.load(vm_config_path(vm)) + build_manager(loader.config) + end + + def selected_config_loaders + case @vm + when 'both' + pair_config_loaders + else + [ConfigLoader.load(vm_config_path(@vm))] + end + end + # Parses the shared --replace / --dry-run / --vllm / --ollama / --model flags - # used by both 'create' and 'create-both'. When include_model_preset is false - # (create-both), the --model flag is not registered because each VM uses its own + # used by 'create' and by 'create --vm both'. When include_model_preset is false + # (both), the --model flag is not registered because each VM uses its own # 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, @@ -148,7 +132,7 @@ module HyperstackVM opts end - def parse_delete_both_options(argv) + def parse_delete_options(argv) opts = { dry_run: false } OptionParser.new do |o| o.on('--dry-run', 'Show which VMs would be deleted without deleting them') { opts[:dry_run] = true } @@ -180,18 +164,61 @@ module HyperstackVM ) end + def run_test + loaders = selected_config_loaders + loaders.each do |loader| + if loaders.size > 1 + puts + puts "[#{File.basename(loader.path)}]" + end + build_manager(loader.config).test + end + end + + def run_model + sub = @argv.shift + raise Error, 'Missing model subcommand. Use: model list | model switch PRESET [--dry-run]' if sub.nil? + + case sub + when 'list' + loaders = selected_config_loaders + loaders.each do |loader| + if loaders.size > 1 + puts + puts "[#{File.basename(loader.path)}]" + end + build_manager(loader.config).list_models + end + when 'switch' + preset = @argv.shift + raise Error, 'Missing preset name. Usage: model switch PRESET [--dry-run]' if preset.nil? + + dry_run = false + OptionParser.new { |o| o.on('--dry-run') { dry_run = true } }.parse!(@argv) + loaders = selected_config_loaders + loaders.each do |loader| + if loaders.size > 1 + puts + puts "[#{File.basename(loader.path)}]" + end + build_manager(loader.config).switch_model(preset_name: preset, dry_run: dry_run) + end + else + raise Error, "Unknown model subcommand #{sub.inspect}. Use list or switch." + end + end + # Starts the VllmWatcher dashboard restricted to VMs that are currently reachable. # Uses watch_config_loaders instead of status_config_loaders so VMs whose state # files are stale (e.g. deleted from the console without `delete`) are excluded. def run_watch loaders = watch_config_loaders - raise Error, 'No active VMs found. Run `create` or `create-both` first.' if loaders.empty? - + raise Error, 'No active VMs found. Run `create --vm 1|2|both` first.' if loaders.empty? VllmWatcher.new(config_loaders: loaders).run end def run_status - loaders = status_config_loaders + loaders = selected_config_loaders if loaders.one? build_manager(loaders.first.config).status return @@ -214,7 +241,7 @@ module HyperstackVM # Falls back to all state-tracked loaders when none are reachable (e.g. WireGuard down), # so the watcher can still render meaningful error output instead of raising. def watch_config_loaders - loaders = status_config_loaders + loaders = selected_config_loaders reachable = loaders.select { |l| vm_api_reachable?(l.config) } reachable.empty? ? loaders : reachable end @@ -230,20 +257,6 @@ module HyperstackVM false end - def status_config_loaders - return [ConfigLoader.load(@config_path)] if @config_explicit - - candidates = [ - @config_path, - File.join(REPO_ROOT, 'hyperstack-vm1.toml'), - File.join(REPO_ROOT, 'hyperstack-vm2.toml') - ].uniq.select { |path| File.exist?(path) } - - loaders = candidates.map { |path| ConfigLoader.load(path) } - tracked = loaders.select { |loader| File.exist?(loader.config.state_file) } - tracked.empty? ? [ConfigLoader.load(@config_path)] : tracked - end - def pair_config_loaders [ ConfigLoader.load(File.join(REPO_ROOT, 'hyperstack-vm1.toml')), |
