remote_ruby 0.1

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.
@@ -0,0 +1,97 @@
1
+ module RemoteRuby
2
+ # An adapter to expecute Ruby code in the current process in an isolated
3
+ # scope
4
+ class EvalAdapter < ConnectionAdapter
5
+ attr_reader :async, :working_dir
6
+
7
+ def initialize(working_dir: Dir.pwd, async: false)
8
+ @async = async
9
+ @working_dir = working_dir
10
+ end
11
+
12
+ def connection_name
13
+ ''
14
+ end
15
+
16
+ def open(code)
17
+ if async
18
+ run_async(code) do |out, err|
19
+ yield out, err
20
+ end
21
+ else
22
+ run_sync(code) do |out, err|
23
+ yield out, err
24
+ end
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def run_code(code)
31
+ binder = Object.new
32
+
33
+ Dir.chdir(working_dir) do
34
+ binder.instance_eval(code)
35
+ end
36
+ end
37
+
38
+ def run_sync(code)
39
+ with_stringio do |tmp_stdout, tmp_stderr|
40
+ with_tmp_streams(tmp_stdout, tmp_stderr) do
41
+ run_code(code)
42
+ end
43
+
44
+ tmp_stdout.close_write
45
+ tmp_stderr.close_write
46
+ tmp_stdout.rewind
47
+ tmp_stderr.rewind
48
+ yield tmp_stdout, tmp_stderr
49
+ end
50
+ end
51
+
52
+ def run_async(code)
53
+ with_pipes do |out_read, out_write, err_read, err_write|
54
+ Thread.new do
55
+ with_tmp_streams(out_write, err_write) do
56
+ run_code(code)
57
+ end
58
+
59
+ out_write.close
60
+ err_write.close
61
+ end
62
+
63
+ yield out_read, err_read
64
+ end
65
+ end
66
+
67
+ def with_pipes
68
+ out_read, out_write = IO.pipe
69
+ err_read, err_write = IO.pipe
70
+ yield out_read, out_write, err_read, err_write
71
+ ensure
72
+ out_read.close
73
+ err_read.close
74
+ end
75
+
76
+ def with_stringio
77
+ out = StringIO.new
78
+ err = StringIO.new
79
+
80
+ yield out, err
81
+ ensure
82
+ out.close
83
+ err.close
84
+ end
85
+
86
+ def with_tmp_streams(out, err)
87
+ old_stdout = $stdout
88
+ old_stderr = $stderr
89
+ $stdout = out
90
+ $stderr = err
91
+ yield
92
+ ensure
93
+ $stdout = old_stdout
94
+ $stderr = old_stderr
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,21 @@
1
+ module RemoteRuby
2
+ # An adapter to expecute Ruby code on the local macine
3
+ # inside a specified directory
4
+ class LocalStdinAdapter < ::RemoteRuby::StdinProcessAdapter
5
+ attr_reader :working_dir
6
+
7
+ def initialize(working_dir: '.')
8
+ @working_dir = working_dir
9
+ end
10
+
11
+ def connection_name
12
+ working_dir
13
+ end
14
+
15
+ private
16
+
17
+ def command
18
+ "cd \"#{working_dir}\" && ruby"
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,25 @@
1
+ module RemoteRuby
2
+ # An adapter to execute Ruby code on the remote server via SSH
3
+ class SSHStdinAdapter < StdinProcessAdapter
4
+ attr_reader :server, :working_dir, :user, :key_file
5
+
6
+ def initialize(server:, working_dir: '~', user: nil, key_file: nil)
7
+ @working_dir = working_dir
8
+ @server = user.nil? ? server : "#{user}@#{server}"
9
+ @user = user
10
+ @key_file = key_file
11
+ end
12
+
13
+ def connection_name
14
+ "#{server}:#{working_dir}"
15
+ end
16
+
17
+ private
18
+
19
+ def command
20
+ command = 'ssh'
21
+ command = "#{command} -i #{key_file}" if key_file
22
+ "#{command} #{server} \"cd #{working_dir} && ruby\""
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,33 @@
1
+ require 'open3'
2
+
3
+ module RemoteRuby
4
+ # Base class for adapters which launch an external process to execute
5
+ # Ruby code and send the code to its standard input.
6
+ class StdinProcessAdapter < ::RemoteRuby::ConnectionAdapter
7
+ include Open3
8
+
9
+ def open(code)
10
+ result = nil
11
+
12
+ popen3(command) do |stdin, stdout, stderr, wait_thr|
13
+ stdin.write(code)
14
+ stdin.close
15
+
16
+ yield stdout, stderr
17
+
18
+ result = wait_thr.value
19
+ end
20
+
21
+ return if result.success?
22
+
23
+ raise "Remote connection exited with code #{result}"
24
+ end
25
+
26
+ protected
27
+
28
+ # Command to run an external process. Override in a child class.
29
+ def command
30
+ raise NotImplementedError
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,28 @@
1
+ module RemoteRuby
2
+ # Base class for other connection adapters.
3
+ class ConnectionAdapter
4
+ # Initializers of adapters should receive only keyword arguments.
5
+ # May be overriden in a child class.
6
+ def initialize(**args); end
7
+
8
+ # This will be displayed as a prefix when adapter writes something to
9
+ # emulated standard output or standard error. May be overriden in a child
10
+ # class.
11
+ def connection_name
12
+ self.class.name
13
+ end
14
+
15
+ # Override in child class. Receives Ruby code as string and yields
16
+ # two readable streams: for emulated standard output and standard error.
17
+ def open(_code)
18
+ raise NotImplementedError
19
+ end
20
+ end
21
+ end
22
+
23
+ require 'remote_ruby/connection_adapter/eval_adapter.rb'
24
+ require 'remote_ruby/connection_adapter/stdin_process_adapter'
25
+ require 'remote_ruby/connection_adapter/ssh_stdin_adapter'
26
+ require 'remote_ruby/connection_adapter/local_stdin_adapter'
27
+ require 'remote_ruby/connection_adapter/cache_adapter'
28
+ require 'remote_ruby/connection_adapter/caching_adapter'
@@ -0,0 +1,134 @@
1
+ require 'digest'
2
+ require 'fileutils'
3
+
4
+ require 'remote_ruby/compiler'
5
+ require 'remote_ruby/connection_adapter'
6
+ require 'remote_ruby/locals_extractor'
7
+ require 'remote_ruby/source_extractor'
8
+ require 'remote_ruby/flavour'
9
+ require 'remote_ruby/runner'
10
+
11
+ module RemoteRuby
12
+ # This class is responsible for executing blocks on the remote host with the
13
+ # specified adapters. This is the entrypoint to RemoteRuby logic.
14
+ class ExecutionContext
15
+ # rubocop:disable Metrics/CyclomaticComplexity
16
+ def initialize(**params)
17
+ add_flavours(params)
18
+ @use_cache = params.delete(:use_cache) || false
19
+ @save_cache = params.delete(:save_cache) || false
20
+ @cache_dir = params.delete(:cache_dir) || File.join(Dir.pwd, 'cache')
21
+ @out_stream = params.delete(:out_stream) || $stdout
22
+ @err_stream = params.delete(:err_stream) || $stderr
23
+ @adapter_klass = params.delete(:adapter) || ::RemoteRuby::SSHStdinAdapter
24
+ @params = params
25
+
26
+ FileUtils.mkdir_p(@cache_dir)
27
+ end
28
+ # rubocop:enable Metrics/CyclomaticComplexity
29
+
30
+ def execute(locals = nil, &block)
31
+ source = code_source(block)
32
+ locals ||= extract_locals(block)
33
+
34
+ result = execute_code(source, **locals)
35
+
36
+ assign_locals(locals.keys, result[:locals], block)
37
+
38
+ result[:result]
39
+ end
40
+
41
+ private
42
+
43
+ attr_reader :params, :adapter_klass, :use_cache, :save_cache, :cache_dir,
44
+ :out_stream, :err_stream, :flavours
45
+
46
+ def assign_locals(local_names, values, block)
47
+ local_names.each do |local|
48
+ next unless values.key?(local)
49
+ block.binding.local_variable_set(local, values[local])
50
+ end
51
+ end
52
+
53
+ def extract_locals(block)
54
+ extractor =
55
+ ::RemoteRuby::LocalsExtractor.new(block, ignore_types: self.class)
56
+ extractor.locals
57
+ end
58
+
59
+ def code_source(block)
60
+ source_extractor = ::RemoteRuby::SourceExtractor.new
61
+ source_extractor.extract(&block)
62
+ end
63
+
64
+ def context_hash(code_hash)
65
+ Digest::MD5.hexdigest(
66
+ self.class.name +
67
+ adapter_klass.name.to_s +
68
+ params.to_s +
69
+ code_hash
70
+ )
71
+ end
72
+
73
+ def cache_path(code_hash)
74
+ hsh = context_hash(code_hash)
75
+ File.join(cache_dir, hsh)
76
+ end
77
+
78
+ def cache_exists?(code_hash)
79
+ hsh = cache_path(code_hash)
80
+ File.exist?("#{hsh}.stdout") || File.exist?("#{hsh}.stderr")
81
+ end
82
+
83
+ def compiler(ruby_code, client_locals)
84
+ RemoteRuby::Compiler.new(
85
+ ruby_code,
86
+ client_locals: client_locals,
87
+ flavours: flavours
88
+ )
89
+ end
90
+
91
+ def execute_code(ruby_code, client_locals = {})
92
+ compiler = compiler(ruby_code, client_locals)
93
+
94
+ runner = ::RemoteRuby::Runner.new(
95
+ code: compiler.compiled_code,
96
+ adapter: adapter(compiler.code_hash),
97
+ out_stream: out_stream,
98
+ err_stream: err_stream
99
+ )
100
+
101
+ runner.run
102
+ end
103
+
104
+ def adapter(code_hash)
105
+ actual_adapter = adapter_klass.new(params)
106
+
107
+ if use_cache && cache_exists?(code_hash)
108
+ cache_adapter(actual_adapter, code_hash)
109
+ elsif save_cache
110
+ caching_adapter(actual_adapter, code_hash)
111
+ else
112
+ actual_adapter
113
+ end
114
+ end
115
+
116
+ def cache_adapter(adapter, code_hash)
117
+ ::RemoteRuby::CacheAdapter.new(
118
+ connection_name: adapter.connection_name,
119
+ cache_path: cache_path(code_hash)
120
+ )
121
+ end
122
+
123
+ def caching_adapter(adapter, code_hash)
124
+ ::RemoteRuby::CachingAdapter.new(
125
+ adapter: adapter,
126
+ cache_path: cache_path(code_hash)
127
+ )
128
+ end
129
+
130
+ def add_flavours(params)
131
+ @flavours = ::RemoteRuby::Flavour.build_flavours(params)
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,19 @@
1
+ module RemoteRuby
2
+ # Flavour to load Rails environment
3
+ class RailsFlavour < ::RemoteRuby::Flavour
4
+ def initialize(environment: :development)
5
+ @environment = environment
6
+ end
7
+
8
+ def code_header
9
+ <<-RUBY
10
+ ENV['RAILS_ENV'] = '#{environment}'
11
+ require './config/environment'
12
+ RUBY
13
+ end
14
+
15
+ private
16
+
17
+ attr_reader :environment
18
+ end
19
+ end
@@ -0,0 +1,25 @@
1
+ module RemoteRuby
2
+ # Base class for Flavours: addons to execution context to insert additonal
3
+ # code to the generated remote code.
4
+ class Flavour
5
+ def self.build_flavours(params = {})
6
+ res = []
7
+
8
+ {
9
+ rails: RemoteRuby::RailsFlavour
10
+ }.each do |name, klass|
11
+ options = params.delete(name)
12
+
13
+ res << klass.new(**options) if options
14
+ end
15
+
16
+ res
17
+ end
18
+
19
+ def initialize(params: {}); end
20
+
21
+ def code_header; end
22
+ end
23
+ end
24
+
25
+ require 'remote_ruby/flavour/rails_flavour'
@@ -0,0 +1,41 @@
1
+ module RemoteRuby
2
+ # Extracts local variable from given context
3
+ class LocalsExtractor
4
+ attr_reader :block, :ignore_types
5
+
6
+ def initialize(block, ignore_types: [])
7
+ @block = block
8
+ @ignore_types = Array(ignore_types)
9
+ end
10
+
11
+ def locals
12
+ locals = {}
13
+
14
+ local_variable_names.each do |name|
15
+ value = block.binding.eval(name.to_s)
16
+ next if ignored_type?(value)
17
+ locals[name] = value
18
+ end
19
+
20
+ locals
21
+ end
22
+
23
+ private
24
+
25
+ def local_variable_names
26
+ if RUBY_VERSION >= '2.2'
27
+ block.binding.local_variables
28
+ else
29
+ # A hack to support Ruby 2.1 due to the absence
30
+ # of Binding#local_variables method. For some reason
31
+ # just calling `block.binding.send(:local_variables)`
32
+ # returns variables of the current context.
33
+ block.binding.eval('binding.send(:local_variables)')
34
+ end
35
+ end
36
+
37
+ def ignored_type?(var)
38
+ ignore_types.any? { |klass| var.is_a? klass }
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,53 @@
1
+ require 'colorize'
2
+
3
+ require 'remote_ruby/unmarshaler'
4
+
5
+ module RemoteRuby
6
+ # Runner class is responsible for running a prepared Ruby code with given
7
+ # connection adapter, reading output and unmarshalling result and local
8
+ # variables values.
9
+ class Runner
10
+ def initialize(code:, adapter:, out_stream: $stdout, err_stream: $stderr)
11
+ @code = code
12
+ @adapter = adapter
13
+ @out_stream = out_stream
14
+ @err_stream = err_stream
15
+ end
16
+
17
+ def run
18
+ locals = nil
19
+
20
+ adapter.open(code) do |stdout, stderr|
21
+ out_thread = read_stream(stdout, out_stream, :green)
22
+ err_thread = read_stream(stderr, err_stream, :red)
23
+ [out_thread, err_thread].each(&:join)
24
+ locals = out_thread[:locals]
25
+ end
26
+
27
+ { result: locals[:__return_val__], locals: locals }
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :code, :adapter, :out_stream, :err_stream
33
+
34
+ def read_stream(read_from, write_to, color)
35
+ Thread.new do
36
+ until read_from.eof?
37
+ line = read_from.readline
38
+
39
+ if line.start_with?('%%%MARSHAL')
40
+ Thread.current[:locals] ||= unmarshal(read_from)
41
+ else
42
+ write_to.puts "#{adapter.connection_name.send(color)}>\t#{line}"
43
+ end
44
+ end
45
+ end
46
+ end
47
+
48
+ def unmarshal(stdout)
49
+ unmarshaler = RemoteRuby::Unmarshaler.new(stdout)
50
+ unmarshaler.unmarshal
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,39 @@
1
+ require 'method_source'
2
+ require 'parser/current'
3
+ require 'unparser'
4
+
5
+ module RemoteRuby
6
+ # Receives a block and extracts Ruby code (as a string) with this block's
7
+ # source
8
+ class SourceExtractor
9
+ def extract(&block)
10
+ ast = Parser::CurrentRuby.parse(block.source)
11
+ block_node = find_block(ast)
12
+
13
+ return '' unless block_node
14
+
15
+ _, body = parse(block_node)
16
+ Unparser.unparse(body)
17
+ end
18
+
19
+ private
20
+
21
+ def find_block(node)
22
+ return nil unless node.is_a? AST::Node
23
+ return node if node.type == :block
24
+
25
+ node.children.each do |child|
26
+ res = find_block(child)
27
+ return res if res
28
+ end
29
+
30
+ nil
31
+ end
32
+
33
+ def parse(node)
34
+ args = node.children[1].children
35
+ body = node.children[2]
36
+ [args, body]
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,34 @@
1
+ module RemoteRuby
2
+ # Decorates the source stream and writes to the cache stream as
3
+ # the source is being read
4
+ class StreamCacher
5
+ def initialize(source_stream, cache_stream)
6
+ @source_stream = source_stream
7
+ @cache_stream = cache_stream
8
+ end
9
+
10
+ def read(*args)
11
+ res = source_stream.read(*args)
12
+ cache_stream.write(res)
13
+ res
14
+ end
15
+
16
+ def readline
17
+ res = source_stream.readline
18
+ cache_stream.write(res)
19
+ res
20
+ end
21
+
22
+ def eof?
23
+ source_stream.eof?
24
+ end
25
+
26
+ def close
27
+ source_stream.close
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :source_stream, :cache_stream
33
+ end
34
+ end
@@ -0,0 +1,58 @@
1
+ module RemoteRuby
2
+ # Unmarshals variables from given stream
3
+ class Unmarshaler
4
+ UnmarshalError = Class.new(StandardError)
5
+
6
+ def initialize(stream, terminator = nil)
7
+ @stream = stream
8
+ @terminator = terminator
9
+ end
10
+
11
+ def unmarshal
12
+ res = {}
13
+
14
+ until stream.eof?
15
+ line = stream.readline
16
+
17
+ break if terminator && line == terminator
18
+
19
+ var = read_var(line)
20
+ res[var.first] = var[1]
21
+ end
22
+
23
+ res
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :stream, :terminator
29
+
30
+ def read_var(line)
31
+ varname, length = read_var_header(line)
32
+ data = read_var_data(length)
33
+ [varname.to_sym, data]
34
+ rescue ArgumentError => e
35
+ raise UnmarshalError,
36
+ "Could not resolve type for #{varname} variable: #{e.message}"
37
+ rescue TypeError
38
+ raise UnmarshalError, 'Incorrect data format'
39
+ end
40
+
41
+ def read_var_header(line)
42
+ varname, length = line.split(':')
43
+
44
+ if varname.nil? || length.nil?
45
+ raise UnmarshalError, "Incorrect header '#{line}'"
46
+ end
47
+
48
+ [varname, length]
49
+ end
50
+
51
+ # rubocop:disable Security/MarshalLoad
52
+ def read_var_data(length)
53
+ data = stream.read(length.to_i)
54
+ Marshal.load(data)
55
+ end
56
+ # rubocop:enable Security/MarshalLoad
57
+ end
58
+ end
@@ -0,0 +1,3 @@
1
+ module RemoteRuby
2
+ VERSION = '0.1'.freeze
3
+ end
@@ -0,0 +1,25 @@
1
+ require 'remote_ruby/version'
2
+ require 'remote_ruby/execution_context'
3
+
4
+ # Namespace module for other RemoteRuby classes. Also contains methods, which
5
+ # are included in the global scope
6
+ module RemoteRuby
7
+ def remotely(args = {}, &block)
8
+ locals = args.delete(:locals)
9
+ execution_context = ::RemoteRuby::ExecutionContext.new(**args)
10
+ execution_context.execute(locals, &block)
11
+ end
12
+
13
+ def self.root(*params)
14
+ root_dir = ::Gem::Specification.find_by_name('remote_ruby').gem_dir
15
+ File.join(root_dir, *params)
16
+ end
17
+
18
+ def self.lib_path(*params)
19
+ File.join(root, 'lib', *params)
20
+ end
21
+ end
22
+
23
+ # rubocop:disable Style/MixinUsage
24
+ include RemoteRuby
25
+ # rubocop:enable Style/MixinUsage
@@ -0,0 +1,29 @@
1
+ lib = File.expand_path('lib', __dir__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require 'remote_ruby/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'remote_ruby'
7
+ spec.version = RemoteRuby::VERSION
8
+ spec.authors = ['Nikita Chernukhin']
9
+ spec.email = ['nuinuhin@gmail.com']
10
+
11
+ spec.summary = 'Execute Ruby code on the remote servers.'
12
+ spec.description =
13
+ 'Execute Ruby code on the remote servers from local Ruby script.'
14
+ spec.homepage = 'https://github.com/nu-hin/remote_ruby'
15
+ spec.license = 'MIT'
16
+
17
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
18
+ f.match(%r{^(test|spec|features)/})
19
+ end
20
+
21
+ spec.bindir = 'bin'
22
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
23
+ spec.require_paths = ['lib']
24
+
25
+ spec.add_runtime_dependency 'colorize', '~> 0.8'
26
+ spec.add_runtime_dependency 'method_source', '~> 0.9'
27
+ spec.add_runtime_dependency 'parser', '~> 2.5'
28
+ spec.add_runtime_dependency 'unparser', '~> 0.2'
29
+ end