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,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