opswalrus 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,132 @@
1
+ require 'open3'
2
+ require 'fileutils'
3
+
4
+ module SSHKit
5
+
6
+ module Backend
7
+
8
+ # this backend is compatible with sudo if you use the -S flag, e.g.: sudo -S ...
9
+ class LocalNonBlocking < Local
10
+
11
+ private
12
+
13
+ def execute_command(cmd)
14
+ output.log_command_start(cmd.with_redaction)
15
+ cmd.started = Time.now
16
+ Open3.popen3(cmd.to_command) do |stdin, stdout, stderr, wait_thr|
17
+ stdout_thread = Thread.new do
18
+ buffer = ""
19
+ partial_buffer = ""
20
+ while !stdout.closed?
21
+ # puts "9" * 80
22
+ begin
23
+ stdout.read_nonblock(4096, partial_buffer)
24
+ buffer << partial_buffer
25
+ # puts "nonblocking1. buffer=#{buffer} partial_buffer=#{partial_buffer}"
26
+ buffer = handle_data_for_stdout(output, cmd, buffer, stdin, false)
27
+ # puts "nonblocking2. buffer=#{buffer} partial_buffer=#{partial_buffer}"
28
+ rescue IO::WaitReadable, Errno::EAGAIN, Errno::EWOULDBLOCK
29
+ # puts "blocking. buffer=#{buffer} partial_buffer=#{partial_buffer}"
30
+ buffer = handle_data_for_stdout(output, cmd, buffer, stdin, true)
31
+ IO.select([stdout])
32
+ retry
33
+
34
+ # per https://stackoverflow.com/questions/1154846/continuously-read-from-stdout-of-external-process-in-ruby
35
+ # and https://stackoverflow.com/questions/10238298/ruby-on-linux-pty-goes-away-without-eof-raises-errnoeio
36
+ # the PTY can raise an Errno::EIO because the child process unexpectedly goes away
37
+ rescue EOFError, Errno::EIO
38
+ # puts "eof!"
39
+ handle_data_for_stdout(output, cmd, buffer, stdin, true)
40
+ stdout.close
41
+ rescue => e
42
+ puts "closing PTY due to unexpected error: #{e.message}"
43
+ handle_data_for_stdout(output, cmd, buffer, stdin, true)
44
+ stdout.close
45
+ # puts e.message
46
+ # puts e.backtrace.join("\n")
47
+ end
48
+ end
49
+ # puts "end!"
50
+ end
51
+ stderr_thread = Thread.new do
52
+ buffer = ""
53
+ partial_buffer = ""
54
+ while !stderr.closed?
55
+ # puts "9" * 80
56
+ begin
57
+ stderr.read_nonblock(4096, partial_buffer)
58
+ buffer << partial_buffer
59
+ # puts "nonblocking1. buffer=#{buffer} partial_buffer=#{partial_buffer}"
60
+ buffer = handle_data_for_stderr(output, cmd, buffer, stdin, false)
61
+ # puts "nonblocking2. buffer=#{buffer} partial_buffer=#{partial_buffer}"
62
+ rescue IO::WaitReadable, Errno::EAGAIN, Errno::EWOULDBLOCK
63
+ # puts "blocking. buffer=#{buffer} partial_buffer=#{partial_buffer}"
64
+ buffer = handle_data_for_stderr(output, cmd, buffer, stdin, true)
65
+ IO.select([stderr])
66
+ retry
67
+
68
+ # per https://stackoverflow.com/questions/1154846/continuously-read-from-stdout-of-external-process-in-ruby
69
+ # and https://stackoverflow.com/questions/10238298/ruby-on-linux-pty-goes-away-without-eof-raises-errnoeio
70
+ # the PTY can raise an Errno::EIO because the child process unexpectedly goes away
71
+ rescue EOFError, Errno::EIO
72
+ # puts "eof!"
73
+ handle_data_for_stderr(output, cmd, buffer, stdin, true)
74
+ stderr.close
75
+ rescue => e
76
+ puts "closing PTY due to unexpected error: #{e.message}"
77
+ handle_data_for_stderr(output, cmd, buffer, stdin, true)
78
+ stderr.close
79
+ # puts e.message
80
+ # puts e.backtrace.join("\n")
81
+ end
82
+ end
83
+ # puts "end!"
84
+ end
85
+ stdout_thread.join
86
+ stderr_thread.join
87
+ cmd.exit_status = wait_thr.value.to_i
88
+ output.log_command_exit(cmd)
89
+ end
90
+ end
91
+
92
+
93
+ # returns [complete lines, new buffer]
94
+ def split_buffer(buffer)
95
+ lines = buffer.split(/(\r\n)\r|\n/)
96
+ buffer = lines.pop
97
+ [lines, buffer]
98
+ end
99
+
100
+ def handle_data_for_stdout(output, cmd, buffer, stdin, is_blocked)
101
+ # we're blocked on reading, so let's process the buffer
102
+ lines, buffer = split_buffer(buffer)
103
+ lines.each do |line|
104
+ cmd.on_stdout(stdin, line)
105
+ output.log_command_data(cmd, :stdout, line)
106
+ end
107
+ if is_blocked && buffer
108
+ cmd.on_stdout(stdin, buffer)
109
+ output.log_command_data(cmd, :stdout, buffer)
110
+ buffer = ""
111
+ end
112
+ buffer || ""
113
+ end
114
+
115
+
116
+ def handle_data_for_stderr(output, cmd, buffer, stdin, is_blocked)
117
+ # we're blocked on reading, so let's process the buffer
118
+ lines, buffer = split_buffer(buffer)
119
+ lines.each do |line|
120
+ cmd.on_stderr(stdin, line)
121
+ output.log_command_data(cmd, :stderr, line)
122
+ end
123
+ if is_blocked && buffer
124
+ cmd.on_stderr(stdin, buffer)
125
+ output.log_command_data(cmd, :stderr, buffer)
126
+ buffer = ""
127
+ end
128
+ buffer || ""
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,89 @@
1
+ require 'open3'
2
+ require 'pty'
3
+ require 'fileutils'
4
+
5
+ module SSHKit
6
+
7
+ module Backend
8
+
9
+ # this backend is compatible with sudo, even without the -S flag, e.g.: sudo ...
10
+ class LocalPty < Local
11
+
12
+ private
13
+
14
+ def execute_command(cmd)
15
+ output.log_command_start(cmd.with_redaction)
16
+ cmd.started = Time.now
17
+ PTY.spawn(cmd.to_command) do |stdout, stdin, pid|
18
+ stdout_thread = Thread.new do
19
+ buffer = ""
20
+ partial_buffer = ""
21
+ while !stdout.closed?
22
+ # puts "9" * 80
23
+ begin
24
+ stdout.read_nonblock(4096, partial_buffer)
25
+ buffer << partial_buffer
26
+ # puts "nonblocking1. buffer=#{buffer} partial_buffer=#{partial_buffer}"
27
+ buffer = handle_data_for_stdout(output, cmd, buffer, stdin, false)
28
+ # puts "nonblocking2. buffer=#{buffer} partial_buffer=#{partial_buffer}"
29
+ rescue IO::WaitReadable, Errno::EAGAIN, Errno::EWOULDBLOCK
30
+ # puts "blocking. buffer=#{buffer} partial_buffer=#{partial_buffer}"
31
+ buffer = handle_data_for_stdout(output, cmd, buffer, stdin, true)
32
+ IO.select([stdout])
33
+ retry
34
+
35
+ # per https://stackoverflow.com/questions/1154846/continuously-read-from-stdout-of-external-process-in-ruby
36
+ # and https://stackoverflow.com/questions/10238298/ruby-on-linux-pty-goes-away-without-eof-raises-errnoeio
37
+ # the PTY can raise an Errno::EIO because the child process unexpectedly goes away
38
+ rescue EOFError, Errno::EIO
39
+ # puts "eof!"
40
+ handle_data_for_stdout(output, cmd, buffer, stdin, true)
41
+ stdout.close
42
+ rescue => e
43
+ puts "closing PTY due to unexpected error: #{e.message}"
44
+ handle_data_for_stdout(output, cmd, buffer, stdin, true)
45
+ stdout.close
46
+ # puts e.message
47
+ # puts e.backtrace.join("\n")
48
+ end
49
+ end
50
+ # puts "end!"
51
+
52
+ end
53
+ stdout_thread.join
54
+ _pid, status = Process.wait2(pid)
55
+ cmd.exit_status = status.exitstatus
56
+ output.log_command_exit(cmd)
57
+ end
58
+ end
59
+
60
+ # returns [complete lines, new buffer]
61
+ def split_buffer(buffer)
62
+ lines = buffer.split(/(\r\n)\r|\n/)
63
+ buffer = lines.pop
64
+ [lines, buffer]
65
+ end
66
+
67
+ def handle_data_for_stdout(output, cmd, buffer, stdin, is_blocked)
68
+ # we're blocked on reading, so let's process the buffer
69
+ lines, buffer = split_buffer(buffer)
70
+ lines.each do |line|
71
+ # puts "1" * 80
72
+ # puts line
73
+ cmd.on_stdout(stdin, line)
74
+ output.log_command_data(cmd, :stdout, line)
75
+ end
76
+ if is_blocked && buffer
77
+ # puts "2" * 80
78
+ # puts buffer
79
+ cmd.on_stdout(stdin, buffer)
80
+ output.log_command_data(cmd, :stdout, buffer)
81
+ buffer = ""
82
+ end
83
+ buffer || ""
84
+ end
85
+
86
+
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,85 @@
1
+ require "random/formatter"
2
+ require "socket"
3
+
4
+ require_relative "runtime_environment"
5
+ require_relative "ops_file"
6
+
7
+ module OpsWalrus
8
+ class OperationRunner
9
+ attr_accessor :app
10
+ attr_accessor :entry_point_ops_file
11
+
12
+ def initialize(app, entry_point_ops_file)
13
+ @app = app
14
+ @entry_point_ops_file = entry_point_ops_file
15
+ # @entry_point_ops_file_in_bundle_dir = bundle!(@entry_point_ops_file)
16
+ end
17
+
18
+ # def bundle!(entry_point_ops_file)
19
+ # path_to_entry_point_ops_file_in_bundle_dir = @app.bundler.build_bundle_for_ops_file(entry_point_ops_file)
20
+ # OpsFile.new(app, path_to_entry_point_ops_file_in_bundle_dir)
21
+ # end
22
+
23
+ def sudo_user
24
+ @app.sudo_user
25
+ end
26
+
27
+ def sudo_password
28
+ @app.sudo_password
29
+ end
30
+
31
+ # runtime_kv_args is an Array(String) of the form: ["arg1:val1", "arg2:val2", ...]
32
+ # params_json_hash is a Hash representation of a JSON string
33
+ def run(runtime_kv_args, params_json_hash: nil, verbose: false)
34
+ params_hash = runtime_kv_args.reduce(params_json_hash || {}) do |memo, kv_pair_string|
35
+ str_key, str_value = kv_pair_string.split(":", 2)
36
+ if pre_existing_value = memo[str_key]
37
+ array = pre_existing_value.is_a?(Array) ? pre_existing_value : [pre_existing_value]
38
+ array << str_value
39
+ memo[str_key] = array
40
+ else
41
+ memo[str_key] = str_value
42
+ end
43
+ memo
44
+ end
45
+
46
+ if verbose == 2
47
+ puts "Script:"
48
+ puts @entry_point_ops_file.script
49
+ end
50
+
51
+ result = begin
52
+ # update the bundle for the package
53
+ # @entry_point_ops_file.package_file&.bundle! # we don't do this here because when the script is run
54
+ # on a remote host, the package references may be invalid
55
+ # so we will be unable to bundle at runtime on the remote host
56
+ catch(:exit_now) do
57
+ ruby_script_return = RuntimeEnvironment.new(app).run(@entry_point_ops_file, params_hash)
58
+ Invocation::Success.new(ruby_script_return)
59
+ end
60
+ rescue SSHKit::Command::Failed => e
61
+ puts "[!] Command failed: #{e.message}"
62
+ rescue Error => e
63
+ $stderr.puts "Error: Ops script crashed."
64
+ $stderr.puts e.message
65
+ $stderr.puts e.backtrace.join("\n")
66
+ Invocation::Error.new(e)
67
+ rescue => e
68
+ $stderr.puts "Unhandled Error: Ops script crashed."
69
+ $stderr.puts e.class
70
+ $stderr.puts e.message
71
+ $stderr.puts e.backtrace.join("\n")
72
+ Invocation::Error.new(e)
73
+ end
74
+
75
+ if verbose && result.failure?
76
+ puts "Ops script error details:"
77
+ puts "Error: #{result.value}"
78
+ puts "Status code: #{result.exit_status}"
79
+ puts @entry_point_ops_file.script
80
+ end
81
+
82
+ result
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,235 @@
1
+ require 'pathname'
2
+ require 'yaml'
3
+ require_relative 'ops_file_script'
4
+
5
+ module OpsWalrus
6
+
7
+ class OpsFile
8
+ attr_accessor :app
9
+ attr_accessor :ops_file_path
10
+ attr_accessor :yaml
11
+ attr_accessor :script
12
+
13
+ def initialize(app, ops_file_path)
14
+ @app = app
15
+ @ops_file_path = ops_file_path.to_pathname.expand_path
16
+ end
17
+
18
+ def hash
19
+ @ops_file_path.hash
20
+ end
21
+
22
+ def eql?(other)
23
+ self.class == other.class && self.hash == other.hash
24
+ end
25
+
26
+ def yaml
27
+ @yaml || (load_file && @yaml)
28
+ end
29
+
30
+ def script
31
+ @script || (load_file && @script)
32
+ end
33
+
34
+ def load_file
35
+ yaml, ruby_script = if @ops_file_path.exist?
36
+ parse(File.read(@ops_file_path))
37
+ end || ["", ""]
38
+ @yaml = YAML.load(yaml) || {} # post_invariant: @yaml is a Hash
39
+ @script = OpsFileScript.new(self, ruby_script)
40
+ end
41
+
42
+ def parse(script_string)
43
+ file_halves = script_string.split(/^\.\.\.$/, 2)
44
+ case file_halves.count
45
+ when 1
46
+ yaml, ruby_script = "", file_halves
47
+ when 2
48
+ yaml, ruby_script = *file_halves
49
+ else
50
+ raise Error, "Unexpected number of file sections: #{file_halves.inspect}"
51
+ end
52
+ [yaml, ruby_script]
53
+ end
54
+
55
+ def params
56
+ yaml["params"]
57
+ end
58
+
59
+ def output
60
+ yaml["output"]
61
+ end
62
+
63
+ def package_file
64
+ return @package_file if @package_file_evaluated
65
+ @package_file ||= begin
66
+ ops_file_path = @ops_file_path.realpath
67
+ ops_file_path.ascend.each do |path|
68
+ candidate_package_file_path = path.join("package.yaml")
69
+ return PackageFile.new(candidate_package_file_path) if candidate_package_file_path.exist?
70
+ end
71
+ nil
72
+ end
73
+ @package_file_evaluated = true
74
+ @package_file
75
+ end
76
+
77
+ # returns a map of the form: {"local_symbol" => import_reference, ... }
78
+ # import_reference is one of:
79
+ # 1. a package reference that matches one of the local package names in the dependencies captured in packages.yaml
80
+ # 2. a package reference that resolves to a relative path pointing at a package directory
81
+ # 3. a path that resolves to a directory containing ops files
82
+ # 4. a path that resolves to an ops file
83
+ def imports
84
+ @imports ||= begin
85
+ imports_hash = yaml["imports"] || {}
86
+ imports_hash.map do |local_name, yaml_import_reference|
87
+ local_name = local_name.to_s
88
+ import_reference = case yaml_import_reference
89
+ in String => import_str
90
+ case
91
+ when package_reference = package_file&.dependency(import_str) # package dependency reference
92
+ # in this context, import_str is the local package name documented in the package's dependencies
93
+ PackageDependencyReference.new(local_name, package_reference)
94
+ when import_str.to_pathname.exist? # path reference
95
+ path = import_str.to_pathname
96
+ case
97
+ when path.directory?
98
+ DirectoryReference.new(local_name, path.realpath)
99
+ when path.file? && path.extname.downcase == ".ops"
100
+ OpsFileReference.new(local_name, path.realpath)
101
+ else
102
+ raise Error, "Unknown import reference: #{local_name} -> #{import_str.inspect}"
103
+ end
104
+ end
105
+ # in Hash
106
+ # url = package_defn["url"]
107
+ # version = package_defn["version"]
108
+ # PackageReference.new(local_name, url, version&.to_s)
109
+ else
110
+ raise Error, "Unknown package reference: #{package_defn.inspect}"
111
+ end
112
+ [local_name, import_reference]
113
+ end.to_h
114
+ end
115
+ end
116
+
117
+ def invoke(runtime_env, params_hash)
118
+ puts "invoking: #{ops_file_path}"
119
+ script.invoke(runtime_env, params_hash)
120
+ end
121
+
122
+ def build_params_hash(*args, **kwargs)
123
+ params_hash = {}
124
+
125
+ # if there is only one Hash object in args, treat that as the params hash
126
+ if args.size == 1 && args.first.is_a?(Hash)
127
+ tmp_params_hash = args.first.transform_keys(&:to_s)
128
+ params_hash.merge!(tmp_params_hash)
129
+ end
130
+
131
+ # if there are the same number of args as there are params, then treat each one as the corresponding param
132
+ if args.size == params.keys.size
133
+ tmp_params_hash = params.keys.zip(args).to_h.transform_keys(&:to_s)
134
+ params_hash.merge!(tmp_params_hash)
135
+ end
136
+
137
+ # merge in the kwargs as part of the params hash
138
+ params_hash.merge!(kwargs.transform_keys(&:to_s))
139
+
140
+ params_hash
141
+ end
142
+
143
+ # symbol table derived from explicit imports and the import for the private lib directory if it exists
144
+ # map of: "symbol_name" => ImportReference
145
+ def local_symbol_table
146
+ @local_symbol_table ||= begin
147
+ local_symbol_table = {}
148
+
149
+ local_symbol_table.merge(imports)
150
+
151
+ # this is the import for the private lib directory if it exists
152
+ if private_lib_dir.exist?
153
+ local_symbol_table[basename.to_s] = DirectoryReference.new(basename.to_s, private_lib_dir)
154
+ end
155
+
156
+ local_symbol_table
157
+ end
158
+ end
159
+
160
+ def resolve_import(symbol_name)
161
+ local_symbol_table[symbol_name]
162
+ end
163
+
164
+ # def namespace
165
+ # @namespace ||= begin
166
+ # ns = Namespace.new
167
+ # sibling_ops_files.each do |ops_file|
168
+ # ns.add(ops_file.basename, ops_file)
169
+ # end
170
+ # sibling_directories.each do |dir_path|
171
+ # dir_basename = dir_path.basename
172
+ # ns.add(dir_basename, ) unless resolve_symbol.resolve_symbol(dir_basename)
173
+ # end
174
+ # ns
175
+ # end
176
+ # end
177
+
178
+ # "/home/david/sync/projects/ops/ops/core/host/info.ops" => "/home/david/sync/projects/ops/ops/core/host"
179
+ def dirname
180
+ @ops_file_path.dirname
181
+ end
182
+
183
+ # "/home/david/sync/projects/ops/ops/core/host/info.ops" => "info"
184
+ def basename
185
+ @ops_file_path.basename(".ops")
186
+ end
187
+
188
+ # "/home/david/sync/projects/ops/ops/core/host/info.ops" => "/home/david/sync/projects/ops/ops/core/host/info"
189
+ def private_lib_dir
190
+ dirname.join(basename)
191
+ end
192
+
193
+ def sibling_ops_files
194
+ dirname.glob("*.ops").map {|path| OpsFile.new(app, path) }
195
+ end
196
+
197
+ # irb(main):073:0> OpsFile.new("/home/david/sync/projects/ops/example/davidinfra/test.ops").sibling_directories
198
+ # => [#<Pathname:/home/david/sync/projects/ops/example/davidinfra/caddy>, #<Pathname:/home/david/sync/projects/ops/example/davidinfra/prepare_host>, #<Pathname:/home/david/sync/projects/ops/example/davidinfra/roles>]
199
+ def sibling_directories
200
+ dirname.glob("*").select(&:directory?)
201
+ end
202
+
203
+
204
+
205
+
206
+
207
+
208
+
209
+
210
+ def supporting_library_include_dir_and_require_lib
211
+ if Dir.exist?(ops_file_helper_library_directory)
212
+ [ops_file_helper_library_directory, ops_file_helper_library_basename]
213
+ elsif File.exist?(ops_file_sibling_helper_library_file)
214
+ [dirname, ops_file_helper_library_basename]
215
+ else
216
+ [nil, nil]
217
+ end
218
+ end
219
+
220
+ def ops_file_helper_library_basename
221
+ basename.sub_ext(".rb")
222
+ end
223
+
224
+ # "/home/david/sync/projects/ops/ops/core/host/info.ops" => "/home/david/sync/projects/ops/ops/core/host/info"
225
+ def ops_file_helper_library_directory
226
+ File.join(dirname, basename)
227
+ end
228
+
229
+ # "/home/david/sync/projects/ops/ops/core/host/info.ops" => "/home/david/sync/projects/ops/ops/core/host/info.rb"
230
+ def ops_file_sibling_helper_library_file
231
+ "#{ops_file_helper_library_directory}.rb"
232
+ end
233
+
234
+ end
235
+ end