remote_ruby 0.2.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +1 -2
  3. data/.rubocop.yml +37 -6
  4. data/CHANGELOG.md +64 -0
  5. data/LICENSE.txt +1 -1
  6. data/README.md +348 -79
  7. data/lib/remote_ruby/adapter_builder.rb +75 -0
  8. data/lib/remote_ruby/cache_adapter.rb +41 -0
  9. data/lib/remote_ruby/{connection_adapter/caching_adapter.rb → caching_adapter.rb} +23 -13
  10. data/lib/remote_ruby/code_templates/compiler/main.rb.erb +17 -29
  11. data/lib/remote_ruby/compat_io_reader.rb +36 -0
  12. data/lib/remote_ruby/compat_io_writer.rb +38 -0
  13. data/lib/remote_ruby/compiler.rb +8 -8
  14. data/lib/remote_ruby/connection_adapter.rb +16 -16
  15. data/lib/remote_ruby/execution_context.rb +56 -74
  16. data/lib/remote_ruby/extensions.rb +14 -0
  17. data/lib/remote_ruby/parser_factory.rb +29 -0
  18. data/lib/remote_ruby/plugin.rb +25 -0
  19. data/lib/remote_ruby/{flavour/rails_flavour.rb → rails_plugin.rb} +7 -5
  20. data/lib/remote_ruby/remote_context.rb +52 -0
  21. data/lib/remote_ruby/remote_error.rb +55 -0
  22. data/lib/remote_ruby/source_extractor.rb +2 -12
  23. data/lib/remote_ruby/ssh_adapter.rb +128 -0
  24. data/lib/remote_ruby/stream_prefixer.rb +25 -0
  25. data/lib/remote_ruby/tee_writer.rb +16 -0
  26. data/lib/remote_ruby/text_mode_adapter.rb +63 -0
  27. data/lib/remote_ruby/text_mode_builder.rb +44 -0
  28. data/lib/remote_ruby/tmp_file_adapter.rb +62 -0
  29. data/lib/remote_ruby/version.rb +1 -1
  30. data/lib/remote_ruby.rb +57 -15
  31. metadata +72 -28
  32. data/.github/workflows/main.yml +0 -26
  33. data/.gitignore +0 -17
  34. data/Gemfile +0 -23
  35. data/lib/remote_ruby/connection_adapter/cache_adapter.rb +0 -41
  36. data/lib/remote_ruby/connection_adapter/eval_adapter.rb +0 -96
  37. data/lib/remote_ruby/connection_adapter/local_stdin_adapter.rb +0 -24
  38. data/lib/remote_ruby/connection_adapter/ssh_stdin_adapter.rb +0 -28
  39. data/lib/remote_ruby/connection_adapter/stdin_process_adapter.rb +0 -35
  40. data/lib/remote_ruby/flavour.rb +0 -27
  41. data/lib/remote_ruby/runner.rb +0 -55
  42. data/lib/remote_ruby/stream_cacher.rb +0 -36
  43. data/lib/remote_ruby/unmarshaler.rb +0 -59
  44. data/remote_ruby.gemspec +0 -36
@@ -1,17 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'remote_ruby/plugin'
4
+
3
5
  module RemoteRuby
4
- # Flavour to load Rails environment
5
- class RailsFlavour < ::RemoteRuby::Flavour
6
+ # Plugin to load Rails environment
7
+ class RailsPlugin < ::RemoteRuby::Plugin
6
8
  def initialize(environment: :development)
7
9
  super
8
10
  @environment = environment
9
11
  end
10
12
 
11
13
  def code_header
12
- <<-RUBY
13
- ENV['RAILS_ENV'] = '#{environment}'
14
- require './config/environment'
14
+ <<~RUBY
15
+ ENV['RAILS_ENV'] = '#{environment}'
16
+ require './config/environment'
15
17
  RUBY
16
18
  end
17
19
 
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+
5
+ module RemoteRuby
6
+ # This class is inlined to the remote script and used
7
+ # to collect errors and other information about the remote run.
8
+ # It is serialized and sent back to the client.
9
+ class RemoteContext
10
+ attr_reader :file_name, :has_error, :error_class, :error_message, :error_backtrace, :locals, :result
11
+
12
+ def initialize(filename)
13
+ @file_name = filename
14
+ @has_error = false
15
+ @locals = {}
16
+ end
17
+
18
+ def error?
19
+ @has_error
20
+ end
21
+
22
+ def handle_error(err)
23
+ @error_class = err.class.to_s
24
+ @error_message = err.message
25
+ @error_backtrace = err.backtrace
26
+ @has_error = true
27
+ end
28
+
29
+ def execute(&block)
30
+ @result = begin
31
+ block.call
32
+ rescue StandardError => e
33
+ handle_error(e)
34
+ ensure
35
+ locals.each_key do |name|
36
+ locals[name] = block.binding.local_variable_get(name)
37
+ end
38
+ end
39
+ end
40
+
41
+ def dump
42
+ Marshal.dump(self)
43
+ end
44
+
45
+ def unmarshal(name, data)
46
+ locals[name] = Marshal.load(Base64.strict_decode64(data)) # rubocop:disable Security/MarshalLoad
47
+ rescue ArgumentError
48
+ warn("Warning: could not resolve type for '#{name}' variable")
49
+ nil
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stringio'
4
+ module RemoteRuby
5
+ # Raised when an error occurs during remote execution
6
+ # Wraps the original error and provides additional information
7
+ # about the error and the source code that caused it.
8
+ # Allows to display the source code around the line that caused the error.
9
+ class RemoteError < StandardError
10
+ attr_reader :code_source, :remote_context, :source_path, :stack_trace_regexp
11
+
12
+ def initialize(code_source, remote_context, source_path)
13
+ @code_source = code_source
14
+ @remote_context = remote_context
15
+ @source_path = source_path
16
+ @stack_trace_regexp = /^#{Regexp.escape(remote_context.file_name)}:(?<line_number>\d+):in (?<method_name>.*)$/
17
+ super(build_message)
18
+ end
19
+
20
+ private
21
+
22
+ def format_source(line_no, context_lines: 3)
23
+ code_source.lines.each.with_index(1).drop(line_no - context_lines - 1)
24
+ .take((2 * context_lines) + 1).map do |line, index|
25
+ if index == line_no
26
+ "#{index}: >> #{line}"
27
+ else
28
+ "#{index}: #{line}"
29
+ end
30
+ end
31
+ end
32
+
33
+ def build_message
34
+ res = StringIO.new
35
+ res.puts "Remote error: #{remote_context.error_class}"
36
+ res.puts remote_context.error_message
37
+
38
+ write_backtrace(res)
39
+
40
+ res.string
41
+ end
42
+
43
+ def write_backtrace(res)
44
+ remote_context.error_backtrace.each do |line|
45
+ res.puts
46
+ res.puts "from #{line}"
47
+
48
+ next unless (m = stack_trace_regexp.match(line))
49
+
50
+ res.puts "(See #{source_path}:#{m[:line_number]}:in #{m[:method_name]}" if source_path
51
+ res.puts format_source(m[:line_number].to_i)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -1,24 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'method_source'
4
- require 'parser/current'
5
- require 'unparser'
6
-
7
- # Opt-in to most recent AST format
8
- Parser::Builders::Default.emit_lambda = true
9
- Parser::Builders::Default.emit_procarg0 = true
10
- Parser::Builders::Default.emit_encoding = true
11
- Parser::Builders::Default.emit_index = true
12
- Parser::Builders::Default.emit_arg_inside_procarg0 = true
13
- Parser::Builders::Default.emit_forward_arg = true
14
- Parser::Builders::Default.emit_kwargs = true
15
- Parser::Builders::Default.emit_match_pattern = true
4
+ require 'remote_ruby/parser_factory'
16
5
 
17
6
  module RemoteRuby
18
7
  # Receives a block and extracts Ruby code (as a string) with this block's
19
8
  # source
20
9
  class SourceExtractor
21
10
  def extract(&block)
11
+ RemoteRuby::ParserFactory.require_parser
22
12
  ast = Parser::CurrentRuby.parse(block.source)
23
13
  block_node = find_block(ast)
24
14
 
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/ssh'
4
+ require 'remote_ruby/compat_io_reader'
5
+
6
+ module RemoteRuby
7
+ # An adapter for executing Ruby code on a remote host via SSH
8
+ class SSHAdapter < ConnectionAdapter
9
+ UnableToExecuteError = Class.new(StandardError)
10
+
11
+ attr_reader :host, :config, :working_dir, :user
12
+
13
+ def initialize(host:, working_dir: nil, use_ssh_config_file: true, **params)
14
+ super
15
+ @host = host
16
+ @working_dir = working_dir
17
+ @config = Net::SSH.configuration_for(@host, use_ssh_config_file)
18
+
19
+ @config = @config.merge(params)
20
+ @user = @config[:user]
21
+ end
22
+
23
+ def open(code, stdin, stdout, stderr)
24
+ ret = nil
25
+ Net::SSH.start(host, nil, config) do |ssh|
26
+ with_temp_file(code, ssh) do |fname|
27
+ res = run_code(ssh, fname, stdin, stdout, stderr)
28
+ raise "Process exited with code #{status}" unless res.zero?
29
+
30
+ ret = get_result(ssh, fname)
31
+ end
32
+ end
33
+ ret
34
+ end
35
+
36
+ def connection_name
37
+ "#{user}@#{host}:#{working_dir || '~'}> "
38
+ end
39
+
40
+ private
41
+
42
+ def handle_stdin(chan, stdin)
43
+ return if stdin.nil?
44
+
45
+ if stdin.is_a?(StringIO)
46
+ chan.send_data(stdin.string)
47
+ chan.eof!
48
+ return
49
+ end
50
+
51
+ stdin = RemoteRuby::CompatIOReader.new(stdin)
52
+
53
+ chan.connection.listen_to(stdin.readable) do |io|
54
+ data = io.read_nonblock(4096)
55
+ chan.send_data(data)
56
+ rescue EOFError
57
+ chan.connection.stop_listening_to(stdin.readable)
58
+ chan.eof!
59
+ end
60
+
61
+ chan.on_close do
62
+ chan.connection.stop_listening_to(stdin.readable)
63
+ stdin.join
64
+ end
65
+ end
66
+
67
+ def handle_stdout(chan, stdout)
68
+ return if stdout.nil?
69
+
70
+ chan.on_data do |_, data|
71
+ stdout.write(data)
72
+ end
73
+ end
74
+
75
+ def handle_stderr(chan, stderr)
76
+ return if stderr.nil?
77
+
78
+ chan.on_extended_data do |_, _, data|
79
+ stderr.write(data)
80
+ end
81
+ end
82
+
83
+ def handle_exit_code(chan)
84
+ chan.on_request('exit-status') do |_, data|
85
+ yield data.read_long
86
+ end
87
+ end
88
+
89
+ def run_remote_process(ssh, cmd, stdin, stdout, stderr)
90
+ res = nil
91
+
92
+ ssh.open_channel do |channel|
93
+ channel.exec(cmd) do |ch, success|
94
+ raise UnableToExecuteError unless success
95
+
96
+ handle_stdin(ch, stdin)
97
+ handle_stdout(ch, stdout)
98
+ handle_stderr(ch, stderr)
99
+ handle_exit_code(ch) do |code|
100
+ res = code
101
+ end
102
+ end
103
+ end.wait
104
+
105
+ res
106
+ end
107
+
108
+ def run_code(ssh, fname, stdin, stdout, stderr)
109
+ cmd = "cd '#{working_dir}' && ruby \"#{fname}\""
110
+ run_remote_process(ssh, cmd, stdin, stdout, stderr)
111
+ end
112
+
113
+ def get_result(ssh, fname)
114
+ ssh.exec!("cat \"#{fname}\"")
115
+ end
116
+
117
+ def with_temp_file(code, ssh)
118
+ out = StringIO.new
119
+ cmd = 'f=$(mktemp --tmpdir remote_ruby.XXXXXX) && cat > $f && echo $f'
120
+ run_remote_process(ssh, cmd, StringIO.new(code), out, nil)
121
+ fname = out.string.strip
122
+
123
+ yield fname
124
+ ensure
125
+ ssh.exec!("rm \"#{fname}\"")
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RemoteRuby
4
+ # Decorates the source stream prepending a prefix to each line
5
+ # read from the source
6
+ class StreamPrefixer
7
+ attr_reader :stream, :prefix
8
+
9
+ def initialize(stream, prefix)
10
+ @stream = stream
11
+ @prefix = prefix
12
+ @prefix_needed = true
13
+ end
14
+
15
+ def write(data)
16
+ res = 0
17
+ data.each_line do |line|
18
+ res += stream.write(prefix) if @prefix_needed
19
+ @prefix_needed = line.end_with?("\n")
20
+ res += stream.write(line)
21
+ end
22
+ res
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RemoteRuby
4
+ # Implements a tee writer that writes to multiple writers
5
+ class TeeWriter
6
+ attr_reader :writers
7
+
8
+ def initialize(*writers)
9
+ @writers = writers
10
+ end
11
+
12
+ def write(*args)
13
+ writers.map { |writer| writer.write(*args) }.min
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'remote_ruby/stream_prefixer'
4
+ require 'colorize'
5
+
6
+ module RemoteRuby
7
+ # Decorates a connection adapter.
8
+ # Reads the output streams line-by-line and prefixes them according to the settings.
9
+ class TextModeAdapter < ConnectionAdapter
10
+ DEFAULT_SETTINGS = {
11
+ stdout_mode: { color: :green, mode: :italic },
12
+ stderr_mode: { color: :red, mode: :italic },
13
+ cache_mode: { color: :blue, mode: :bold },
14
+ cache_prefix: '[C] '
15
+ }.freeze
16
+
17
+ attr_reader :adapter, :stdout_prefix, :stderr_prefix, :cache_prefix, :stdout_mode, :stderr_mode,
18
+ :cache_mode
19
+
20
+ def initialize(adapter, **params)
21
+ super()
22
+ @adapter = adapter
23
+ @stdout_prefix = params[:stdout_prefix]
24
+ @stderr_prefix = params[:stderr_prefix]
25
+ @cache_prefix = params[:cache_prefix]
26
+ @stdout_mode = params[:stdout_mode]
27
+ @stderr_mode = params[:stderr_mode]
28
+ @cache_mode = params[:cache_mode]
29
+ end
30
+
31
+ def open(code, stdin, stdout, stderr)
32
+ stdout_pref = "#{cache_prefix_string}#{stdout_prefix_string}"
33
+ stderr_pref = "#{cache_prefix_string}#{stderr_prefix_string}"
34
+ stdout = StreamPrefixer.new(stdout, stdout_pref) unless stdout_prefix_string.nil?
35
+ stderr = StreamPrefixer.new(stderr, stderr_pref) unless stderr_prefix_string.nil?
36
+
37
+ adapter.open(code, stdin, stdout, stderr)
38
+ end
39
+
40
+ private
41
+
42
+ def stdout_prefix_string
43
+ return nil if stdout_prefix.nil? || stdout_prefix.empty?
44
+ return stdout_prefix if stdout_mode.nil?
45
+
46
+ stdout_prefix.colorize(stdout_mode)
47
+ end
48
+
49
+ def stderr_prefix_string
50
+ return nil if stderr_prefix.nil? || stderr_prefix.empty?
51
+ return stderr_prefix if stderr_mode.nil?
52
+
53
+ stderr_prefix.colorize(stderr_mode)
54
+ end
55
+
56
+ def cache_prefix_string
57
+ return nil if cache_prefix.nil? || cache_prefix.empty?
58
+ return cache_prefix if cache_mode.nil?
59
+
60
+ cache_prefix.colorize(cache_mode)
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RemoteRuby
4
+ # Wraps the connection adapter in a text mode adapter if text mode is enabled.
5
+ class TextModeBuilder
6
+ attr_reader :out_tty, :err_tty, :text_mode
7
+
8
+ def initialize(params:, out_tty: true, err_tty: true)
9
+ @out_tty = out_tty
10
+ @err_tty = err_tty
11
+ @text_mode = params.delete(:text_mode) || false
12
+ end
13
+
14
+ def build(adapter)
15
+ return adapter unless text_mode
16
+
17
+ cache_mode = adapter.is_a? CacheAdapter
18
+
19
+ tm_params = text_mode_params(adapter, cache_mode, out_tty, err_tty)
20
+
21
+ return adapter unless tm_params[:stdout_prefix] || tm_params[:stderr_prefix]
22
+
23
+ ::RemoteRuby::TextModeAdapter.new(adapter, **tm_params)
24
+ end
25
+
26
+ private
27
+
28
+ def text_mode_params(adapter, cache_mode, out_tty, err_tty)
29
+ tm_params = ::RemoteRuby::TextModeAdapter::DEFAULT_SETTINGS.merge(
30
+ stdout_prefix: adapter.connection_name,
31
+ stderr_prefix: adapter.connection_name
32
+ )
33
+
34
+ tm_params = tm_params.merge(text_mode) if text_mode.is_a? Hash
35
+
36
+ disable_unless_tty = tm_params.delete(:disable_unless_tty) { |_| true }
37
+
38
+ tm_params[:stdout_prefix] = nil if disable_unless_tty && !out_tty
39
+ tm_params[:stderr_prefix] = nil if disable_unless_tty && !err_tty
40
+ tm_params[:cache_prefix] = nil unless cache_mode
41
+ tm_params
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tempfile'
4
+ require 'remote_ruby/compat_io_reader'
5
+ require 'remote_ruby/compat_io_writer'
6
+
7
+ module RemoteRuby
8
+ # An adapter to expecute Ruby code on the local machine
9
+ # inside a temporary file
10
+ class TmpFileAdapter < ::RemoteRuby::ConnectionAdapter
11
+ attr_reader :working_dir
12
+
13
+ def initialize(working_dir: Dir.pwd)
14
+ super
15
+ @working_dir = working_dir
16
+ end
17
+
18
+ def open(code, stdin, stdout, stderr)
19
+ result = nil
20
+
21
+ stdin = RemoteRuby::CompatIOReader.new(stdin)
22
+ stdout = RemoteRuby::CompatIOWriter.new(stdout)
23
+ stderr = RemoteRuby::CompatIOWriter.new(stderr)
24
+
25
+ with_temp_file(code) do |filename|
26
+ pid = Process.spawn(
27
+ command(filename),
28
+ in: stdin.readable,
29
+ out: stdout.writeable,
30
+ err: stderr.writeable
31
+ )
32
+
33
+ _, status = Process.wait2(pid)
34
+ raise "Process exited with code #{status}" unless status.success?
35
+
36
+ [stdin, stdout, stderr].each(&:join)
37
+
38
+ result = File.binread(filename)
39
+ end
40
+ result
41
+ end
42
+
43
+ def connection_name
44
+ "#{ENV.fetch('USER', nil)}@localhost:#{working_dir}> "
45
+ end
46
+
47
+ protected
48
+
49
+ def with_temp_file(code)
50
+ f = Tempfile.create('remote_ruby')
51
+ f.write(code)
52
+ f.close
53
+ yield f.path
54
+ ensure
55
+ File.unlink(f.path)
56
+ end
57
+
58
+ def command(code_path)
59
+ "cd \"#{working_dir}\" && ruby \"#{code_path}\""
60
+ end
61
+ end
62
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RemoteRuby
4
- VERSION = '0.2.1'
4
+ VERSION = '1.0.0'
5
5
  end
data/lib/remote_ruby.rb CHANGED
@@ -1,27 +1,69 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'remote_ruby/version'
4
- require 'remote_ruby/execution_context'
4
+ require 'remote_ruby/rails_plugin'
5
+ require 'remote_ruby/extensions'
5
6
 
6
7
  # Namespace module for other RemoteRuby classes. Also contains methods, which
7
8
  # are included in the global scope
8
9
  module RemoteRuby
9
- def remotely(args = {}, &block)
10
- locals = args.delete(:locals)
11
- execution_context = ::RemoteRuby::ExecutionContext.new(**args)
12
- execution_context.execute(locals, &block)
13
- end
10
+ DEFAULT_CONFIG_DIR_NAME = '.remote_ruby'
11
+ DEFAULT_CACHE_DIR_NAME = 'cache'
12
+ DEFAULT_CODE_DIR_NAME = 'code'
14
13
 
15
- def self.root(*params)
16
- root_dir = ::Gem::Specification.find_by_name('remote_ruby').gem_dir
17
- File.join(root_dir, *params)
18
- end
14
+ class << self
15
+ attr_reader :plugins, :ignored_types
16
+ attr_accessor :cache_dir, :code_dir, :suppress_parser_warnings
17
+
18
+ def root(*params)
19
+ root_dir = ::Gem::Specification.find_by_name('remote_ruby').gem_dir
20
+ File.join(root_dir, *params)
21
+ end
22
+
23
+ def ensure_cache_dir
24
+ FileUtils.mkdir_p(cache_dir)
25
+ end
26
+
27
+ def ensure_code_dir
28
+ FileUtils.mkdir_p(code_dir)
29
+ end
30
+
31
+ def lib_path(*params)
32
+ File.join(root, 'lib', *params)
33
+ end
34
+
35
+ def clear_cache
36
+ FileUtils.rm_rf(cache_dir)
37
+ end
19
38
 
20
- def self.lib_path(*params)
21
- File.join(root, 'lib', *params)
39
+ def clear_code
40
+ FileUtils.rm_rf(code_dir)
41
+ end
42
+
43
+ def register_plugin(name, klass)
44
+ @plugins ||= {}
45
+ @plugins[name] = klass
46
+ end
47
+
48
+ def ignore_types(*types)
49
+ @ignored_types ||= []
50
+ @ignored_types.concat(types)
51
+ end
52
+
53
+ def configure
54
+ yield self
55
+ end
22
56
  end
23
57
  end
24
58
 
25
- # rubocop:disable Style/MixinUsage
26
- include RemoteRuby
27
- # rubocop:enable Style/MixinUsage
59
+ RemoteRuby.configure do |config|
60
+ config_dir = File.join(Dir.pwd, RemoteRuby::DEFAULT_CONFIG_DIR_NAME)
61
+ config.cache_dir = File.join(config_dir, RemoteRuby::DEFAULT_CACHE_DIR_NAME)
62
+ config.code_dir = File.join(config_dir, RemoteRuby::DEFAULT_CODE_DIR_NAME)
63
+
64
+ config.ignore_types RemoteRuby::ExecutionContext
65
+
66
+ config.suppress_parser_warnings = false
67
+
68
+ config.register_plugin(:rails, RemoteRuby::RailsPlugin)
69
+ end