opswalrus 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,472 @@
1
+ require 'json'
2
+ require 'set'
3
+ require 'shellwords'
4
+ require 'socket'
5
+ require 'stringio'
6
+
7
+ # require 'ed25519'
8
+ require 'sshkit'
9
+ require 'sshkit/dsl'
10
+
11
+ require_relative 'sshkit_ext'
12
+ require_relative 'walrus_lang'
13
+
14
+ module OpsWalrus
15
+ class ArrayOrHashNavigationProxy
16
+ def initialize(array_or_hash)
17
+ @obj = array_or_hash
18
+ end
19
+ def [](index, *args, **kwargs, &block)
20
+ @obj.method(:[]).call(index, *args, **kwargs, &block)
21
+ end
22
+ def method_missing(name, *args, **kwargs, &block)
23
+ case @obj
24
+ when Array
25
+ @obj.method(name).call(*args, **kwargs, &block)
26
+ when Hash
27
+ if @obj.respond_to?(name)
28
+ @obj.method(name).call(*args, **kwargs, &block)
29
+ else
30
+ value = self[name.to_s]
31
+ case value
32
+ when Array, Hash
33
+ ArrayOrHashNavigationProxy.new(value)
34
+ else
35
+ value
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+
42
+ class Params
43
+ # params : Hash
44
+ def initialize(params)
45
+ @params = params
46
+ end
47
+
48
+ def [](key)
49
+ key = key.to_s if key.is_a? Symbol
50
+ @params[key]
51
+ end
52
+
53
+ def dig(*keys)
54
+ # keys = keys.map {|key| key.is_a?(Integer) ? key : key.to_s }
55
+ @params.dig(*keys)
56
+ end
57
+
58
+ def method_missing(name, *args, **kwargs, &block)
59
+ if @params.respond_to?(name)
60
+ @params.method(name).call(*args, **kwargs, &block)
61
+ else
62
+ value = self[name]
63
+ case value
64
+ when Array, Hash
65
+ ArrayOrHashNavigationProxy.new(value)
66
+ else
67
+ value
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ class Env
74
+ # params : Hash
75
+ def initialize(params)
76
+ @params = params
77
+ end
78
+
79
+ def [](key)
80
+ key = key.to_s if key.is_a? Symbol
81
+ @params[key]
82
+ end
83
+
84
+ def dig(*keys)
85
+ keys = keys.map {|key| key.is_a?(Integer) ? key : key.to_s }
86
+ @params.dig(*keys)
87
+ end
88
+
89
+ def method_missing(name, *args, **kwargs, &block)
90
+ if @params.respond_to?(name)
91
+ @params.method(name).call(*args, **kwargs, &block)
92
+ else
93
+ value = self[name]
94
+ case value
95
+ when Array, Hash
96
+ ArrayOrHashNavigationProxy.new(value)
97
+ else
98
+ value
99
+ end
100
+ end
101
+ end
102
+ end
103
+
104
+ # BootstrapLinuxHostShellScript = <<~SCRIPT
105
+ # #!/usr/bin/env bash
106
+ # ...
107
+ # SCRIPT
108
+
109
+ module DSL
110
+ # include SSHKit::DSL
111
+
112
+ def ssh(*args, **kwargs, &block)
113
+ hosts = inventory(*args, **kwargs)
114
+ sshkit_hosts = hosts.map(&:sshkit_host)
115
+ sshkit_host_to_ops_host_map = sshkit_hosts.zip(hosts).to_h
116
+ runtime_env = @runtime_env
117
+ local_host = self
118
+ # bootstrap_shell_script = BootstrapLinuxHostShellScript
119
+ # on sshkit_hosts do |sshkit_host|
120
+ SSHKit::Coordinator.new(sshkit_hosts).each(in: kwargs[:in] || :parallel) do |sshkit_host|
121
+ host = sshkit_host_to_ops_host_map[sshkit_host]
122
+
123
+ # puts "#{host.alias} / #{host}:"
124
+
125
+ begin
126
+ # in this context, self is an instance of one of the subclasses of SSHKit::Backend::Abstract, e.g. SSHKit::Backend::Netssh
127
+ host.set_ssh_connection(self)
128
+
129
+ # copy over bootstrap shell script
130
+ # io = StringIO.new(bootstrap_shell_script)
131
+ io = File.open(__FILE__.to_pathname.dirname.join("bootstrap.sh"))
132
+ upload_success = host.upload(io, "tmpopsbootstrap.sh")
133
+ io.close
134
+ raise Error, "Unable to upload bootstrap shell script to remote host" unless upload_success
135
+ host.execute(:chmod, "755", "tmpopsbootstrap.sh")
136
+ host.execute(:sh, "tmpopsbootstrap.sh")
137
+
138
+ # copy over ops bundle zip file
139
+ zip_bundle_path = runtime_env.zip_bundle_path
140
+ upload_success = host.upload(zip_bundle_path, "tmpops.zip")
141
+ raise Error, "Unable to upload ops bundle to remote host" unless upload_success
142
+
143
+ stdout, stderr, exit_status = host.run_ops("unzip tmpops.zip")
144
+ raise Error, "Unable to unzip ops bundle on remote host" unless exit_status == 0
145
+ tmp_bundle_dir = stdout.strip
146
+
147
+ # we run the block in the context of the host, s.t. `self` within the block evaluates to `host`
148
+ retval = host.instance_exec(local_host, &block) # host is passed as the argument to the block
149
+
150
+ # cleanup
151
+ if tmp_bundle_dir =~ /tmp/ # sanity check the temp path before we blow away something we don't intend
152
+ host.execute(:rm, "-rf", "tmpopsbootstrap.sh", "tmpops.zip", tmp_bundle_dir)
153
+ else
154
+ host.execute(:rm, "-rf", "tmpopsbootstrap.sh", "tmpops.zip")
155
+ end
156
+
157
+ retval
158
+ rescue SSHKit::Command::Failed => e
159
+ puts "[!] Command failed:"
160
+ puts e.message
161
+ rescue Net::SSH::ConnectionTimeout
162
+ puts "[!] The host '#{host}' not alive!"
163
+ rescue Net::SSH::Timeout
164
+ puts "[!] The host '#{host}' disconnected/timeouted unexpectedly!"
165
+ rescue Errno::ECONNREFUSED
166
+ puts "[!] Incorrect port #{port} for #{host}"
167
+ rescue Net::SSH::HostKeyMismatch => e
168
+ puts "[!] The host fingerprint does not match the last observed fingerprint for #{host}"
169
+ puts e.message
170
+ puts "You might try `ssh-keygen -f ~/.ssh/known_hosts -R \"#{host}\"`"
171
+ rescue Net::SSH::AuthenticationFailed
172
+ puts "Wrong Password: #{host} | #{user}:#{password}"
173
+ rescue Net::SSH::Authentication::DisallowedMethod
174
+ puts "[!] The host '#{host}' doesn't accept password authentication method."
175
+ rescue Errno::EHOSTUNREACH => e
176
+ puts "[!] The host '#{host}' is unreachable"
177
+ rescue => e
178
+ puts e.class
179
+ puts e.message
180
+ # puts e.backtrace.join("\n")
181
+ ensure
182
+ host.clear_ssh_connection
183
+ end
184
+ end
185
+ end
186
+
187
+ def inventory(*args, **kwargs)
188
+ tags = args.map(&:to_s)
189
+
190
+ kwargs = kwargs.transform_keys(&:to_s)
191
+ tags.concat(kwargs["tags"]) if kwargs["tags"]
192
+
193
+ @runtime_env.app.inventory(tags)
194
+ end
195
+
196
+ def exit(exit_status)
197
+ result = if exit_status == 0
198
+ Success.new(nil)
199
+ else
200
+ Error.new(nil, exit_status)
201
+ end
202
+ throw :exit_now, result
203
+ end
204
+
205
+ def env(*keys)
206
+ keys = keys.map(&:to_s)
207
+ if keys.empty?
208
+ @env
209
+ else
210
+ @env.dig(*keys)
211
+ end
212
+ end
213
+
214
+ # local_package_name is the local package name defined for the package dependency that is attempting to be referenced
215
+ def import(local_package_name)
216
+ local_package_name = local_package_name.to_s
217
+ package_reference = @ops_file_script.ops_file.package_file&.dependency(local_package_name)
218
+ raise Error, "Unknown package reference: #{local_package_name}" unless package_reference
219
+ import_reference = PackageDependencyReference.new(local_package_name, package_reference)
220
+ # puts "import: #{import_reference.inspect}"
221
+ @runtime_env.resolve_import_reference(@ops_file_script.ops_file, import_reference)
222
+ end
223
+
224
+ def params(*keys)
225
+ keys = keys.map(&:to_s)
226
+ if keys.empty?
227
+ @params
228
+ else
229
+ @params.dig(*keys)
230
+ end
231
+ end
232
+
233
+ # returns the stdout from the command
234
+ def sh(desc_or_cmd = nil, cmd = nil, stdin: nil, &block)
235
+ out, err, status = *shell!(desc_or_cmd, cmd, block, stdin: stdin)
236
+ out
237
+ end
238
+
239
+ # returns the tuple: [stdout, stderr, exit_status]
240
+ def shell(desc_or_cmd = nil, cmd = nil, stdin: nil, &block)
241
+ shell!(desc_or_cmd, cmd, block, stdin: stdin)
242
+ end
243
+
244
+ # returns the tuple: [stdout, stderr, exit_status]
245
+ def shell!(desc_or_cmd = nil, cmd = nil, block = nil, stdin: nil)
246
+ # description = nil
247
+
248
+ return ["", "", 0] if !desc_or_cmd && !cmd && !block # we were told to do nothing; like hitting enter at the bash prompt; we can do nothing successfully
249
+
250
+ description = desc_or_cmd if cmd || block
251
+ cmd = block.call if block
252
+ cmd ||= desc_or_cmd
253
+
254
+ # puts "shell! self: #{self.inspect}"
255
+
256
+ print "[#{@runtime_env.local_hostname}] "
257
+ print "#{description}: " if description
258
+ puts cmd
259
+
260
+ cmd = WalrusLang.render(cmd, block.binding) if block && cmd =~ /{{.*}}/
261
+ return unless cmd && !cmd.strip.empty?
262
+
263
+ #cmd = Shellwords.escape(cmd)
264
+
265
+ sudo_password = @runtime_env.sudo_password
266
+ sudo_password &&= sudo_password.gsub(/\n+$/,'') # remove trailing newlines from sudo_password
267
+
268
+ # puts "shell: #{cmd}"
269
+ # puts "shell: #{cmd.inspect}"
270
+ # puts "sudo_password: #{sudo_password}"
271
+
272
+ # sshkit_cmd = SSHKit::Backend::LocalNonBlocking.new {
273
+ # sshkit_cmd = SSHKit::Backend::LocalPty.new {
274
+ sshkit_cmd = backend.execute_cmd(cmd, interaction_handler: SudoPasswordMapper.new(sudo_password).interaction_handler, verbosity: :info)
275
+ # execute_cmd(cmd, interaction_handler: SudoPromptInteractionHandler.new, verbosity: :info)
276
+ # }.run
277
+
278
+ [sshkit_cmd.full_stdout, sshkit_cmd.full_stderr, sshkit_cmd.exit_status]
279
+ end
280
+
281
+ # def init_brew
282
+ # execute('eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"')
283
+ # end
284
+
285
+ end
286
+
287
+ class OpsFileScript
288
+ attr_accessor :ops_file
289
+
290
+ def initialize(ops_file, ruby_script)
291
+ @ops_file = ops_file
292
+ @ruby_script = ruby_script
293
+ @invocation_class = define_invocation_class
294
+ end
295
+
296
+ def define_invocation_class
297
+ klass = Class.new(Invocation)
298
+
299
+ methods_defined = Set.new
300
+
301
+ # define methods for every import in the script
302
+ ops_file.local_symbol_table.each do |symbol_name, import_reference|
303
+ unless methods_defined.include? symbol_name
304
+ klass.define_method(symbol_name) do |*args, **kwargs, &block|
305
+ # puts "0" * 80
306
+ # puts "@runtime_env.resolve_import_reference(@ops_file_script.ops_file, #{import_reference.inspect})"
307
+ # puts @ops_file_script.ops_file.ops_file_path
308
+ # puts symbol_name
309
+ namespace_or_ops_file = @runtime_env.resolve_import_reference(@ops_file_script.ops_file, import_reference)
310
+ # puts namespace_or_ops_file.inspect
311
+ # puts "0" * 80
312
+ case namespace_or_ops_file
313
+ when Namespace
314
+ namespace_or_ops_file
315
+ when OpsFile
316
+ params_hash = namespace_or_ops_file.build_params_hash(*args, **kwargs)
317
+ namespace_or_ops_file.invoke(@runtime_env, params_hash)
318
+ end
319
+ end
320
+ methods_defined << symbol_name
321
+ end
322
+ end
323
+
324
+ # define methods for every Namespace or OpsFile within the namespace that the OpsFile resides within
325
+ sibling_symbol_table = Set.new
326
+ sibling_symbol_table |= ops_file.dirname.glob("*.ops").map {|ops_file_path| ops_file_path.basename(".ops").to_s } # OpsFiles
327
+ sibling_symbol_table |= ops_file.dirname.glob("*").select(&:directory?).map {|dir_path| dir_path.basename.to_s } # Namespaces
328
+ sibling_symbol_table.each do |symbol_name|
329
+ unless methods_defined.include? symbol_name
330
+ klass.define_method(symbol_name) do |*args, **kwargs, &block|
331
+ # puts "0" * 80
332
+ # puts "@runtime_env.resolve_symbol(@ops_file_script.ops_file, #{symbol_name})"
333
+ # puts @ops_file_script.ops_file.ops_file_path
334
+ # puts symbol_name
335
+ namespace_or_ops_file = @runtime_env.resolve_sibling_symbol(@ops_file_script.ops_file, symbol_name)
336
+ # puts namespace_or_ops_file.inspect
337
+ # puts "0" * 80
338
+ case namespace_or_ops_file
339
+ when Namespace
340
+ namespace_or_ops_file
341
+ when OpsFile
342
+ params_hash = namespace_or_ops_file.build_params_hash(*args, **kwargs)
343
+ namespace_or_ops_file.invoke(@runtime_env, params_hash)
344
+ end
345
+ end
346
+ methods_defined << symbol_name
347
+ end
348
+ end
349
+
350
+ klass
351
+ end
352
+
353
+ def script
354
+ @ruby_script
355
+ end
356
+
357
+ def invoke(runtime_env, params_hash)
358
+ # Invocation.new(self, runtime_env, params_hash).evaluate
359
+ # puts "INVOKE" * 10
360
+ # puts runtime_env.inspect
361
+ @invocation_class.new(self, runtime_env, params_hash).evaluate
362
+ end
363
+
364
+ def to_s
365
+ @ruby_script
366
+ end
367
+ end
368
+
369
+ # An Invocation object represents a stack frame, and the params_hash represents the
370
+ # arguments that the caller has supplied for that stack frame to reference
371
+ class Invocation
372
+ class Result
373
+ attr_accessor :value
374
+ attr_accessor :exit_status
375
+ def initialize(value, exit_status = 0)
376
+ @value = value
377
+ @exit_status = exit_status
378
+ end
379
+ def success?
380
+ !failure?
381
+ end
382
+ def failure?
383
+ !success?
384
+ end
385
+ end
386
+ class Success < Result
387
+ def initialize(value)
388
+ super(value, 0)
389
+ end
390
+ def success?
391
+ true
392
+ end
393
+ end
394
+ class Error < Result
395
+ def initialize(value, exit_status = 1)
396
+ super(value, exit_status == 0 ? 1 : exit_status)
397
+ end
398
+ def failure?
399
+ true
400
+ end
401
+ end
402
+
403
+
404
+ include DSL
405
+
406
+ def initialize(ops_file_script, runtime_env, params_hash)
407
+ @ops_file_script = ops_file_script
408
+ @runtime_env = runtime_env
409
+ @params = Params.new(params_hash)
410
+ end
411
+
412
+ def backend
413
+ @runtime_env.pty
414
+ end
415
+
416
+ def debug?
417
+ @runtime_env.debug?
418
+ end
419
+
420
+ def verbose?
421
+ @runtime_env.verbose?
422
+ end
423
+
424
+ def evaluate
425
+ # catch(:exit_now) do
426
+ eval(@ops_file_script.script)
427
+ # end
428
+ end
429
+
430
+ # def evaluate
431
+ # ruby_script_return = begin
432
+ # catch(:exit_now) do
433
+ # eval(@ops_file_script.script)
434
+ # end
435
+ # rescue => e
436
+ # $stderr.puts "Error: Ops script crashed."
437
+ # $stderr.puts e.message
438
+ # $stderr.puts e.backtrace.join("\n")
439
+ # Error.new(e)
440
+ # end
441
+
442
+ # if ruby_script_return.is_a? Result
443
+ # ruby_script_return
444
+ # else
445
+ # Success.new(ruby_script_return)
446
+ # end
447
+ # end
448
+
449
+ # def method_missing(name, *args, **kwargs, &block)
450
+ # puts "1" * 80
451
+ # import_reference = @ops_file_script.ops_file.resolve_import(name)
452
+ # if import_reference
453
+ # puts "2" * 80
454
+ # resolved_value = @runtime_env.resolve_import_reference(@ops_file_script.ops_file, import_reference)
455
+ # return resolved_value if resolved_value
456
+ # end
457
+
458
+ # puts "3" * 80
459
+ # case namespace_or_ops_file = @runtime_env.resolve_symbol(@ops_file_script.ops_file, name.to_s)
460
+ # when Namespace
461
+ # puts "4" * 80
462
+ # namespace_or_ops_file
463
+ # when OpsFile
464
+ # puts "5" * 80
465
+ # namespace_or_ops_file.invoke(@runtime_env, *args, **kwargs, &block)
466
+ # else
467
+ # raise NoMethodError, "No method named '#{name}'"
468
+ # end
469
+ # end
470
+ end
471
+
472
+ end
@@ -0,0 +1,102 @@
1
+ require 'pathname'
2
+ require 'yaml'
3
+
4
+ require_relative 'bundler'
5
+
6
+ module OpsWalrus
7
+
8
+ class PackageReference
9
+ attr_accessor :local_name
10
+ attr_accessor :package_uri
11
+ attr_accessor :version
12
+
13
+ def initialize(local_name, package_uri, version = nil)
14
+ @local_name, @package_uri, @version = local_name, package_uri, version
15
+ end
16
+
17
+ def sanitized_package_uri
18
+ sanitize_path(@package_uri)
19
+ end
20
+
21
+ def sanitize_path(path)
22
+ # found this at https://apidock.com/rails/v5.2.3/ActiveStorage/Filename/sanitized
23
+ path.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "�").strip.tr("\u{202E}%$|:;/\t\r\n\\", "-")
24
+ end
25
+
26
+ # important: the dirname implemented as the local_name is critical because Bundler#download_package downloads
27
+ # package dependencies to the name that this method returns, which must match the package reference's local name
28
+ # so that later, when the package is being looked up on the load path (in LoadPath#resolve_import_reference),
29
+ # the package reference's referenced git repo or file path may not exist or be available, and so the package
30
+ # reference's local_name is used to look up the name of the directory that the bundled dependency resides at, and so
31
+ # the package reference's local_name must be the name of the directory that the dependency is placed in within the bundle_dir.
32
+ # If this implementation changes, then Bundler#download_package and LoadPath#resolve_import_reference must also
33
+ # change in order for the three things to reconcile with respect to one another, since all three bits of logic are
34
+ # what make bundling package dependencies and loading them function properly.
35
+ def dirname
36
+ local_name
37
+ end
38
+
39
+ end
40
+
41
+ class PackageFile
42
+ attr_accessor :package_file_path
43
+ attr_accessor :yaml
44
+
45
+ def initialize(package_file_path)
46
+ @package_file_path = package_file_path.to_pathname.expand_path
47
+ @yaml = YAML.load(File.read(package_file_path)) if @package_file_path.exist?
48
+ @yaml ||= {}
49
+ end
50
+
51
+ def package_file
52
+ self
53
+ end
54
+
55
+ def bundle!
56
+ bundler_for_package = Bundler.new(dirname)
57
+ bundler_for_package.update
58
+ end
59
+
60
+ def dirname
61
+ @package_file_path.dirname
62
+ end
63
+
64
+ def hash
65
+ @package_file_path.hash
66
+ end
67
+
68
+ def eql?(other)
69
+ self.class == other.class && self.hash == other.hash
70
+ end
71
+
72
+ def containing_directory
73
+ Pathname.new(@package_file_path).parent
74
+ end
75
+
76
+ # returns a map of the form: {"local_package_name" => PackageReference1, ... }
77
+ def dependencies
78
+ @dependencies ||= begin
79
+ dependencies_hash = yaml["dependencies"] || {}
80
+ dependencies_hash.map do |local_name, package_defn|
81
+ package_reference = case package_defn
82
+ in String => package_url
83
+ PackageReference.new(local_name, package_url)
84
+ in Hash
85
+ url = package_defn["url"]
86
+ version = package_defn["version"]
87
+ PackageReference.new(local_name, url, version&.to_s)
88
+ else
89
+ raise Error, "Unknown package reference in #{package_file_path}:\n #{local_name}: #{package_defn.inspect}"
90
+ end
91
+ [local_name, package_reference]
92
+ end.to_h
93
+ end
94
+ end
95
+
96
+ # returns a PackageReference
97
+ def dependency(local_package_name)
98
+ dependencies[local_package_name]
99
+ end
100
+ end
101
+
102
+ end