remote_ruby 0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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