opswalrus 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.
@@ -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