summaryrefslogtreecommitdiff
path: root/lib/hyperstack/ssh_runner.rb
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 /lib/hyperstack/ssh_runner.rb
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.
Diffstat (limited to 'lib/hyperstack/ssh_runner.rb')
-rw-r--r--lib/hyperstack/ssh_runner.rb126
1 files changed, 126 insertions, 0 deletions
diff --git a/lib/hyperstack/ssh_runner.rb b/lib/hyperstack/ssh_runner.rb
new file mode 100644
index 0000000..f41859d
--- /dev/null
+++ b/lib/hyperstack/ssh_runner.rb
@@ -0,0 +1,126 @@
+# frozen_string_literal: true
+
+require 'open3'
+require 'socket'
+
+module HyperstackVM
+ # Executes commands over SSH and manages host-key trust.
+ class SshRunner
+ def initialize(config:, out:)
+ @config = config
+ @out = out
+ end
+
+ def run(host, remote_script)
+ Open3.capture3(*command(host), stdin_data: remote_script)
+ end
+
+ def run_streaming(host, remote_script)
+ combined = +''
+ Open3.popen2e(*command(host)) do |stdin, output, wait_thr|
+ stdin.write(remote_script)
+ stdin.close
+ output.each do |line|
+ combined << line
+ @out.print(line)
+ end
+ return [combined, wait_thr.value]
+ end
+ end
+
+ def tcp_open?(host, port)
+ Socket.tcp(host, port, connect_timeout: @config.ssh_connect_timeout) do |sock|
+ sock.close
+ true
+ end
+ rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::EHOSTUNREACH,
+ Errno::ENETUNREACH, SocketError, IOError
+ false
+ end
+
+ def command(host)
+ cmd = [
+ 'ssh',
+ '-o', 'BatchMode=yes',
+ '-o', 'StrictHostKeyChecking=yes',
+ '-o', "UserKnownHostsFile=#{@config.ssh_known_hosts_path}",
+ '-o', "ConnectTimeout=#{@config.ssh_connect_timeout}",
+ '-p', @config.ssh_port.to_s
+ ]
+ if File.exist?(@config.ssh_private_key_path)
+ cmd.concat(['-i', @config.ssh_private_key_path])
+ else
+ warn_out("SSH private key #{@config.ssh_private_key_path} does not exist; falling back to default ssh-agent identity.")
+ end
+ cmd << "#{@config.ssh_username}@#{host}"
+ cmd << 'bash -se'
+ cmd
+ end
+
+ # Trust / known-hosts management
+
+ def ensure_trusted_host(host)
+ scanned = scan_host_keys(host)
+ return false if scanned.empty?
+
+ existing = known_host_entries
+ if existing.empty?
+ write_known_host_entries(scanned)
+ puts "Pinned SSH host key for #{host} in #{@config.ssh_known_hosts_path}."
+ return true
+ end
+
+ return true if existing == scanned
+
+ warn_out("SSH host key mismatch for #{host}. Replacing cached key (VM was likely recreated).")
+ write_known_host_entries(scanned)
+ true
+ end
+
+ def scan_host_keys(host)
+ stdout, stderr, status = Open3.capture3(
+ 'ssh-keyscan', '-T', @config.ssh_connect_timeout.to_s,
+ '-p', @config.ssh_port.to_s, host
+ )
+ unless status.success?
+ warn_out("ssh-keyscan not ready yet: #{stderr.strip}") unless stderr.to_s.strip.empty?
+ return []
+ end
+ stdout.lines.map(&:strip).reject { |l| l.empty? || l.start_with?('#') }.sort.uniq
+ rescue Errno::ENOENT
+ raise Error, 'ssh-keyscan is required to pin SSH host keys but was not found in PATH.'
+ end
+
+ def delete_known_hosts_file
+ File.delete(@config.ssh_known_hosts_path) if File.exist?(@config.ssh_known_hosts_path)
+ rescue Errno::EACCES => e
+ raise Error, "Cannot delete SSH known_hosts file #{@config.ssh_known_hosts_path}: #{e.message}"
+ end
+
+ def known_host_entries
+ path = @config.ssh_known_hosts_path
+ return [] unless File.exist?(path)
+
+ File.readlines(path, chomp: true).map(&:strip).reject(&:empty?).sort.uniq
+ rescue Errno::EACCES => e
+ raise Error, "Cannot read SSH known_hosts file #{path}: #{e.message}"
+ end
+
+ def write_known_host_entries(entries)
+ path = @config.ssh_known_hosts_path
+ FileUtils.mkdir_p(File.dirname(path))
+ temp = "#{path}.tmp"
+ File.write(temp, "#{entries.join("\n")}\n")
+ File.chmod(0o600, temp)
+ File.rename(temp, path)
+ rescue Errno::EACCES => e
+ raise Error, "Cannot write SSH known_hosts file #{path}: #{e.message}"
+ end
+
+ private
+
+ def warn_out(msg)
+ @out.puts("WARN: #{msg}")
+ end
+ end
+end