diff --git a/.gitignore b/.gitignore index c49c127..9d1cdaa 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ .irbrc rubyshell-*.gem +sshd-config/ diff --git a/.rubocop.yml b/.rubocop.yml index 8f15cf4..7edc3ec 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -2,6 +2,8 @@ AllCops: NewCops: enable TargetRubyVersion: 2.6 SuggestExtensions: false + Exclude: + - "sshd-config/**/*" Style/StringLiterals: Enabled: true diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b1ce6ca --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +services: + ssh-server: + image: lscr.io/linuxserver/openssh-server:latest + environment: + - USER_NAME=testuser + - USER_PASSWORD=testpass + - PASSWORD_ACCESS=true + ports: + - "2222:2222" diff --git a/lib/rubyshell.rb b/lib/rubyshell.rb index fc30d06..bd06a5a 100644 --- a/lib/rubyshell.rb +++ b/lib/rubyshell.rb @@ -17,6 +17,7 @@ require_relative "rubyshell/debugger" require_relative "rubyshell/env_proxy" require_relative "rubyshell/parallel_executor" +require_relative "rubyshell/remote_executor" module RubyShell class << self diff --git a/lib/rubyshell/debugger.rb b/lib/rubyshell/debugger.rb index 6313f74..750f3dc 100644 --- a/lib/rubyshell/debugger.rb +++ b/lib/rubyshell/debugger.rb @@ -5,27 +5,37 @@ module Debugger class << self def run_wrapper(command, debug: nil) if debug || RubyShell.debug? - time_one = Process.clock_gettime(Process::CLOCK_MONOTONIC) - result = yield - time_two = Process.clock_gettime(Process::CLOCK_MONOTONIC) - RubyShell.log(<<~TEXT - \nExecuted: #{command.to_shell.chomp} - Duration: #{format("%.6f", time_two - time_one)}s - Pid: #{result.metadata[:exit_status].pid} - Exit code: #{result.metadata[:exit_status].to_i} - Stdout: #{result.to_s.inspect} - TEXT - ) + log_result(command, result, time_two - time_one) result else yield end end + + private + + def log_result(command, result, duration) + meta = result.metadata + text = +"\nExecuted: #{command.to_shell.chomp}\n" + + if meta[:remote] + text << " Host: #{meta[:host]}\n" + text << " Port: #{meta[:port]}\n" + else + text << " Pid: #{meta[:exit_status].pid}\n" + end + + text << " Duration: #{format("%.6f", duration)}s\n" + text << " Exit code: #{meta[:exit_status].to_i}\n" + text << " Stdout: #{result.to_s.inspect}\n" + + RubyShell.log(text) + end end end end diff --git a/lib/rubyshell/executor.rb b/lib/rubyshell/executor.rb index 045e00f..df30da6 100644 --- a/lib/rubyshell/executor.rb +++ b/lib/rubyshell/executor.rb @@ -16,6 +16,10 @@ def parallel(options = {}, &block) end end + def remote(host, **options, &block) + RubyShell::RemoteExecutor.new(host, **options).evaluate(&block) + end + def method_missing(method_name, *args, **kwargs) command = RubyShell::Command.new(method_name.to_s.gsub(/!$/, ""), *args, **kwargs) diff --git a/lib/rubyshell/remote_executor.rb b/lib/rubyshell/remote_executor.rb new file mode 100644 index 0000000..b785273 --- /dev/null +++ b/lib/rubyshell/remote_executor.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require_relative "ssh" + +module RubyShell + class RemoteExecutor + RemoteCommand = Struct.new(:shell_string) do + def to_shell + shell_string + end + end + + RemoteStatus = Struct.new(:exit_code) do + def to_i + exit_code + end + + def pid + nil + end + + def success? + exit_code.zero? + end + end + + def initialize(host, port: 22, key: nil, timeout: SSH::DEFAULT_TIMEOUT, debug: nil) + @host = host + @port = port + @key = key + @timeout = timeout + @debug = debug + end + + def evaluate(&block) + user, host = parse_host(@host) + @connection = SSH.new(host, user: user, port: @port, key: @key, timeout: @timeout) + + instance_exec(&block) + ensure + @connection&.close + end + + def sh(command, *args, **kwargs) + method_missing(command, *args, **kwargs) + end + + def chain(options = {}, &block) + chainer = RubyShell::ChainContext.new(options).instance_exec(&block) + execute_remote(chainer.to_shell) + end + + def cd(path) + execute_remote("cd '#{path.gsub("'", "'\\''")}'") + end + + def method_missing(method_name, *args, **kwargs) + command = RubyShell::Command.new(method_name.to_s.gsub(/!$/, ""), *args, **kwargs) + + execute_remote(command.to_shell) + end + + def respond_to_missing?(_name, _include_private) + false + end + + private + + def parse_host(host_string) + if host_string.include?("@") + host_string.split("@", 2) + else + [ENV.fetch("USER", nil), host_string] + end + end + + def execute_remote(shell_string) + wrapper = RemoteCommand.new(shell_string) + + RubyShell::Debugger.run_wrapper(wrapper, debug: @debug) do + result = @connection.execute(shell_string) + + unless result.success? + raise RubyShell::CommandError.new( + command: shell_string, + stdout: result.stdout, + stderr: result.stderr, + status: result.exit_code + ) + end + + status = RemoteStatus.new(result.exit_code) + + RubyShell::Results::StringResult.new( + result.stdout, + metadata: { + command: shell_string, + exit_status: status, + remote: true, + host: @host, + port: @port + } + ) + end + end + end +end diff --git a/lib/rubyshell/ssh.rb b/lib/rubyshell/ssh.rb new file mode 100644 index 0000000..db75a15 --- /dev/null +++ b/lib/rubyshell/ssh.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "open3" +require "securerandom" +require "timeout" + +module RubyShell + class SSH + class CommandTimeout < StandardError; end + + Result = Struct.new(:stdout, :stderr, :exit_code) do + alias_method :to_s, :stdout + alias_method :output, :stdout + + def success? + exit_code.zero? + end + + def lines + stdout.lines + end + end + + attr_reader :host, :port, :user + + DEFAULT_TIMEOUT = 30 + + def initialize(host, user:, port: 22, key: nil, timeout: DEFAULT_TIMEOUT) + @host = host + @user = user + @port = port + @timeout = timeout + + cmd = [ + "ssh", + "-o", "StrictHostKeyChecking=no", + "-p", port.to_s, + "-T" + ] + + cmd += ["-i", key] if key + + cmd << "#{user}@#{host}" + + @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(*cmd) + end + + def execute(command, timeout: @timeout) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity + delim = "__RUBYSHELL_#{SecureRandom.hex(8)}__" + + @stdin.puts("#{command}; echo \"#{delim} $?\"; echo #{delim} >&2") + @stdin.flush + + stdout_buf = +"" + stderr_buf = +"" + exit_code = 0 + stdout_done = false + stderr_done = false + ios = [@stdout, @stderr] + + Timeout.timeout(timeout, CommandTimeout, "command timed out after #{timeout}s: #{command}") do + until stdout_done && stderr_done + break if ios.empty? + + readable, = IO.select(ios, nil, nil, 0.1) + next unless readable + + readable.each do |io| + chunk = io.read_nonblock(4096, exception: false) + + if chunk.nil? + ios.delete(io) + stdout_done = true if io == @stdout + stderr_done = true if io == @stderr + next + end + + next if chunk == :wait_readable + + if io == @stdout + stdout_buf << chunk + if stdout_buf.include?(delim) + before, delim_line = stdout_buf.split(delim, 2) + stdout_buf = before + exit_code = delim_line.strip.to_i + stdout_done = true + end + else + stderr_buf << chunk + if stderr_buf.include?(delim) + stderr_buf = stderr_buf.split(delim, 2).first + stderr_done = true + end + end + end + end + end + + Result.new(stdout_buf.chomp, stderr_buf.chomp, exit_code) + end + + def close + [@stdin, @stdout, @stderr].each { |io| io&.close rescue nil } # rubocop:disable Style/RescueModifier + @wait_thread&.join(5) + end + end +end diff --git a/spec/remote_executor_spec.rb b/spec/remote_executor_spec.rb new file mode 100644 index 0000000..4cd4780 --- /dev/null +++ b/spec/remote_executor_spec.rb @@ -0,0 +1,446 @@ +# frozen_string_literal: true + +RSpec.describe RubyShell::RemoteExecutor do + let(:ssh_host) { "testuser@localhost" } + let(:ssh_key) { File.expand_path("../test_key", __dir__) } + let(:ssh_options) { { port: 2222, key: ssh_key } } + + before(:all) do + ssh_available = system( + "ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=2", + "-p", "2222", "-i", File.expand_path("../test_key", __dir__), + "testuser@localhost", "echo ok", + out: File::NULL, err: File::NULL + ) + + unless ssh_available + skip "SSH server not available (run: docker run -d --name ssh-server -p 2222:2222 " \ + "-e USER_NAME=testuser -e PUBLIC_KEY=\"$(cat test_key.pub)\" " \ + "lscr.io/linuxserver/openssh-server:latest)" + end + end + + describe "Basic Remote Execution" do + context "when executing a single command" do + def subject_call + sh.remote(ssh_host, **ssh_options) { echo("hello") } + end + + it "returns the command output" do + expect(subject_call).to eq("hello") + end + + it "returns a StringResult" do + expect(subject_call).to be_a(RubyShell::Results::StringResult) + end + end + + context "when executing multiple commands" do + def subject_call + sh.remote(ssh_host, **ssh_options) do + echo("first") + echo("second") + end + end + + it "returns the last command result" do + expect(subject_call).to eq("second") + end + end + + context "when executing a command with arguments" do + def subject_call + sh.remote(ssh_host, **ssh_options) { echo("-n", "no newline") } + end + + it "passes arguments correctly" do + expect(subject_call).to eq("no newline") + end + end + end + + describe "Remote cd" do + context "when changing directory" do + def subject_call + sh.remote(ssh_host, **ssh_options) do + cd("/tmp") + pwd + end + end + + it "persists across commands" do + expect(subject_call).to eq("/tmp") + end + end + + context "when changing directory multiple times" do + def subject_call + sh.remote(ssh_host, **ssh_options) do + cd("/tmp") + cd("/") + pwd + end + end + + it "tracks the latest directory" do + expect(subject_call).to eq("/") + end + end + end + + describe "Remote sh" do + context "when using sh() to call a hyphenated command" do + def subject_call + sh.remote(ssh_host, **ssh_options) do + sh("wl-copy", "test data") + sh("wl-paste") + end + end + + it "executes remotely instead of locally" do + expect(subject_call).to eq("test data") + end + end + end + + describe "Remote cd with special paths" do + context "when path contains spaces" do + def subject_call + sh.remote(ssh_host, **ssh_options) do + sh("mkdir", "-p", "'/tmp/ruby shell test'") + cd("/tmp/ruby shell test") + pwd + end + end + + after do + sh.remote(ssh_host, **ssh_options) do + sh("rm", "-rf", "'/tmp/ruby shell test'") + end + end + + it "handles spaces in path" do + expect(subject_call).to eq("/tmp/ruby shell test") + end + end + end + + describe "Host Parsing" do + context "when host includes user@" do + before do + ssh = instance_double(RubyShell::SSH) + allow(RubyShell::SSH).to receive(:new).and_return(ssh) + allow(ssh).to receive(:execute).and_return(RubyShell::SSH::Result.new("ok", "", 0)) + allow(ssh).to receive(:close) + + sh.remote("deploy@example.com", **ssh_options) { echo("test") } + end + + it "parses user and host correctly" do + expect(RubyShell::SSH).to have_received(:new).with("example.com", hash_including(user: "deploy")) + end + end + + context "when host has no user@" do + before do + ssh = instance_double(RubyShell::SSH) + allow(RubyShell::SSH).to receive(:new).and_return(ssh) + allow(ssh).to receive(:execute).and_return(RubyShell::SSH::Result.new("ok", "", 0)) + allow(ssh).to receive(:close) + + sh.remote("example.com", **ssh_options) { echo("test") } + end + + it "defaults user to ENV['USER']" do + expect(RubyShell::SSH).to have_received(:new).with("example.com", hash_including(user: ENV.fetch("USER", nil))) + end + end + end + + describe "Error Handling" do + context "when a command fails" do + def subject_call + sh.remote(ssh_host, **ssh_options) { ls("/nonexistent_path_for_test") } + end + + it "raises CommandError" do + expect { subject_call }.to raise_error(RubyShell::CommandError) + end + + it "includes stderr in the error message" do + expect { subject_call }.to raise_error(RubyShell::CommandError, /No such file or directory/) + end + end + + context "when a command fails mid-block" do + def subject_call + sh.remote(ssh_host, **ssh_options) do + echo("before") + ls("/nonexistent_path_for_test") + echo("after") + end + end + + it "raises and stops execution" do + expect { subject_call }.to raise_error(RubyShell::CommandError) + end + end + end + + describe "Result Metadata" do + context "when inspecting result metadata" do + def subject_call + sh.remote(ssh_host, **ssh_options) { echo("meta") } + end + + it "includes remote flag" do + expect(subject_call.metadata[:remote]).to be true + end + + it "includes host" do + expect(subject_call.metadata[:host]).to eq(ssh_host) + end + + it "includes the command" do + expect(subject_call.metadata[:command]).to eq("echo meta") + end + + it "includes exit status" do + expect(subject_call.metadata[:exit_status].to_i).to eq(0) + end + + it "includes a status object with success?" do + expect(subject_call.metadata[:exit_status].success?).to be true + end + end + end + + describe "Connection Lifecycle" do + context "when the block completes normally" do + let(:ssh) { instance_double(RubyShell::SSH) } + + before do + allow(RubyShell::SSH).to receive(:new).and_return(ssh) + allow(ssh).to receive(:execute).and_return(RubyShell::SSH::Result.new("ok", "", 0)) + allow(ssh).to receive(:close) + + sh.remote(ssh_host, **ssh_options) { echo("test") } + end + + it "closes the connection" do + expect(ssh).to have_received(:close) + end + end + + context "when the block raises an error" do + let(:ssh) { instance_double(RubyShell::SSH) } + + before do + allow(RubyShell::SSH).to receive(:new).and_return(ssh) + allow(ssh).to receive(:execute).and_return(RubyShell::SSH::Result.new("", "error", 1)) + allow(ssh).to receive(:close) + + begin + sh.remote(ssh_host, **ssh_options) { ls("/nonexistent") } + rescue RubyShell::CommandError + # expected + end + end + + it "still closes the connection" do + expect(ssh).to have_received(:close) + end + end + end + + describe "Remote chain" do + context "when using chain with pipe" do + def subject_call + sh.remote(ssh_host, **ssh_options) do + chain { echo("hello world") | wc("-w") } + end + end + + it "executes the chain remotely" do + expect(subject_call.strip).to eq("2") + end + + it "returns a StringResult" do + expect(subject_call).to be_a(RubyShell::Results::StringResult) + end + end + + context "when using chain with redirect" do + def subject_call + sh.remote(ssh_host, **ssh_options) do + chain { echo("chain test") > "/tmp/rubyshell_chain_test" } + cat("/tmp/rubyshell_chain_test") + end + end + + after do + sh.remote(ssh_host, **ssh_options) do + rm("-f", "/tmp/rubyshell_chain_test") + end + end + + it "writes and reads via chain" do + expect(subject_call).to eq("chain test") + end + end + + context "when using chain with multiple pipes" do + def subject_call + sh.remote(ssh_host, **ssh_options) do + chain { echo("hello") | cat | cat } + end + end + + it "pipes through all commands" do + expect(subject_call).to eq("hello") + end + end + end + + describe "Remote debug" do + let(:log_output) { [] } + let(:logger) { double("Logger", info: nil) } + + before do + allow(logger).to receive(:info) { |msg| log_output << msg } + allow(RubyShell).to receive(:logger).and_return(logger) + end + + after do + RubyShell.debug = false + end + + context "when debug is enabled" do + def subject_call + sh.remote(ssh_host, **ssh_options, debug: true) { echo("debug test") } + end + + it "returns the command output" do + expect(subject_call).to eq("debug test") + end + + it "logs the command executed" do + subject_call + + expect(log_output.join).to include("Executed: echo debug test") + end + + it "logs the host" do + subject_call + + expect(log_output.join).to include("Host: #{ssh_host}") + end + + it "logs the port" do + subject_call + + expect(log_output.join).to include("Port: 2222") + end + + it "logs the duration" do + subject_call + + expect(log_output.join).to match(/Duration: \d+\.\d+s/) + end + + it "logs the exit code" do + subject_call + + expect(log_output.join).to include("Exit code: 0") + end + + it "logs the stdout" do + subject_call + + expect(log_output.join).to include('Stdout: "debug test"') + end + end + + context "when debug is not enabled" do + def subject_call + sh.remote(ssh_host, **ssh_options) { echo("no debug") } + end + + it "does not log anything" do + subject_call + + expect(log_output).to be_empty + end + end + + context "when debug is enabled with chain" do + def subject_call + sh.remote(ssh_host, **ssh_options, debug: true) do + chain { echo("debug chain") | wc("-w") } + end + end + + it "logs the chained command" do + subject_call + + expect(log_output.join).to include("Executed: echo debug chain | wc -w") + end + + it "logs the duration" do + subject_call + + expect(log_output.join).to match(/Duration: \d+\.\d+s/) + end + + it "logs the exit code" do + subject_call + + expect(log_output.join).to include("Exit code: 0") + end + end + end + + describe "Integration" do + context "when creating and listing files" do + def subject_call + sh.remote(ssh_host, **ssh_options) do + cd("/tmp") + touch("rubyshell_test_file") + ls("rubyshell_test_file") + end + end + + after do + sh.remote(ssh_host, **ssh_options) do + rm("-f", "/tmp/rubyshell_test_file") + end + end + + it "creates and finds the file" do + expect(subject_call).to eq("rubyshell_test_file") + end + end + + context "when using multiple remote blocks sequentially" do + def subject_call + sh.remote(ssh_host, **ssh_options) do + touch("/tmp/rubyshell_multi_test") + end + + sh.remote(ssh_host, **ssh_options) do + ls("/tmp/rubyshell_multi_test") + end + end + + after do + sh.remote(ssh_host, **ssh_options) do + rm("-f", "/tmp/rubyshell_multi_test") + end + end + + it "each block gets an independent connection" do + expect(subject_call).to eq("/tmp/rubyshell_multi_test") + end + end + end +end diff --git a/spec/ssh_spec.rb b/spec/ssh_spec.rb new file mode 100644 index 0000000..3422f8e --- /dev/null +++ b/spec/ssh_spec.rb @@ -0,0 +1,255 @@ +# frozen_string_literal: true + +RSpec.describe RubyShell::SSH do + let(:ssh_key) { File.expand_path("../test_key", __dir__) } + + before(:all) do + ssh_available = system( + "ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=2", + "-p", "2222", "-i", File.expand_path("../test_key", __dir__), + "testuser@localhost", "echo ok", + out: File::NULL, err: File::NULL + ) + + skip "SSH server not available" unless ssh_available + end + + describe "#execute" do + let(:ssh) { described_class.new("localhost", user: "testuser", port: 2222, key: ssh_key) } + + after { ssh.close } + + context "when running a simple command" do + def subject_call + ssh.execute("echo hello") + end + + it "returns stdout" do + expect(subject_call.stdout).to eq("hello") + end + + it "returns empty stderr on success" do + expect(subject_call.stderr).to eq("") + end + + it "returns exit code 0 on success" do + expect(subject_call.exit_code).to eq(0) + end + + it "returns success?" do + expect(subject_call.success?).to be true + end + end + + context "when command writes to stderr" do + def subject_call + ssh.execute("echo error >&2") + end + + it "captures stderr" do + expect(subject_call.stderr).to eq("error") + end + end + + context "when command writes to both stdout and stderr" do + def subject_call + ssh.execute("echo out; echo err >&2") + end + + it "captures stdout" do + expect(subject_call.stdout).to eq("out") + end + + it "captures stderr" do + expect(subject_call.stderr).to eq("err") + end + end + + context "when command fails" do + def subject_call + ssh.execute("ls /nonexistent_test_path") + end + + it "returns non-zero exit code" do + expect(subject_call.exit_code).not_to eq(0) + end + + it "returns success? false" do + expect(subject_call.success?).to be false + end + + it "captures stderr from failed command" do + expect(subject_call.stderr).to include("No such file or directory") + end + end + + context "when command times out" do + let(:ssh) { described_class.new("localhost", user: "testuser", port: 2222, key: ssh_key, timeout: 1) } + + def subject_call + ssh.execute("sleep 10") + end + + it "raises CommandTimeout" do + expect { subject_call }.to raise_error(RubyShell::SSH::CommandTimeout) + end + + it "allows per-command timeout override" do + expect { ssh.execute("sleep 10", timeout: 1) }.to raise_error(RubyShell::SSH::CommandTimeout) + end + end + + context "when maintaining shell state between commands" do + before { ssh.execute("cd /tmp") } + + def subject_call + ssh.execute("pwd") + end + + it "persists directory changes" do + expect(subject_call.stdout).to eq("/tmp") + end + end + + context "when executing after stderr output" do + before { ssh.execute("echo error1 >&2") } + + def subject_call + ssh.execute("echo clean") + end + + it "does not leak stderr between commands" do + expect(subject_call.stderr).to eq("") + end + end + + context "when executing after stdout output" do + before { ssh.execute("echo first") } + + def subject_call + ssh.execute("echo second") + end + + it "does not leak stdout between commands" do + expect(subject_call.stdout).to eq("second") + end + end + end + + describe "Result" do + let(:ssh) { described_class.new("localhost", user: "testuser", port: 2222, key: ssh_key) } + + after { ssh.close } + + context "when using to_s" do + def subject_call + ssh.execute("echo hello") + end + + it "returns stdout" do + expect(subject_call.to_s).to eq("hello") + end + end + + context "when using output alias" do + def subject_call + ssh.execute("echo hello") + end + + it "returns stdout" do + expect(subject_call.output).to eq("hello") + end + end + + context "when using lines" do + def subject_call + ssh.execute("echo -e 'a\nb\nc'") + end + + it "returns an array of lines" do + expect(subject_call.lines.map(&:chomp)).to eq(%w[a b c]) + end + end + end + + describe "#execute with sh() inside remote block" do + context "when using sh to call a hyphenated command" do + def subject_call + sh.remote("testuser@localhost", port: 2222, key: ssh_key) do + sh("wl-paste") + end + end + + it "executes the command remotely" do + expect(subject_call).to be_a(RubyShell::Results::StringResult) + end + end + end + + describe "#close" do + context "when connection was never established" do + def subject_call + described_class.allocate.close + end + + it "does not raise" do + expect { subject_call }.not_to raise_error + end + end + + context "when SSH process already died" do + def subject_call + ssh = described_class.new("localhost", user: "testuser", port: 2222, key: ssh_key) + Process.kill("TERM", ssh.instance_variable_get(:@wait_thread).pid) + sleep 0.2 + ssh.close + end + + it "does not raise" do + expect { subject_call }.not_to raise_error + end + end + + context "when called multiple times" do + def subject_call + ssh = described_class.new("localhost", user: "testuser", port: 2222, key: ssh_key) + ssh.close + ssh.close + end + + it "does not raise" do + expect { subject_call }.not_to raise_error + end + end + end + + describe "pipe EOF handling" do + context "when SSH process dies mid-command" do + def subject_call + ssh = described_class.new("localhost", user: "testuser", port: 2222, key: ssh_key, timeout: 5) + pid = ssh.instance_variable_get(:@wait_thread).pid + + Thread.new do + sleep 0.3 + Process.kill("TERM", pid) + end + + start = Time.now + + begin + ssh.execute("sleep 10") + rescue RubyShell::SSH::CommandTimeout, Errno::EPIPE, IOError + # any of these are acceptable + end + + elapsed = Time.now - start + ssh.close + elapsed + end + + it "returns within timeout instead of hanging" do + expect(subject_call).to be < 3 + end + end + end +end