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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/Gemfile +9 -0
- data/Gemfile.lock +59 -0
- data/LICENSE +674 -0
- data/README.md +256 -0
- data/Rakefile +8 -0
- data/exe/ops +6 -0
- data/exe/run_ops_bundle +22 -0
- data/lib/opswalrus/app.rb +328 -0
- data/lib/opswalrus/bootstrap.sh +57 -0
- data/lib/opswalrus/bootstrap_linux_host1.sh +12 -0
- data/lib/opswalrus/bootstrap_linux_host2.sh +37 -0
- data/lib/opswalrus/bootstrap_linux_host3.sh +21 -0
- data/lib/opswalrus/bundler.rb +175 -0
- data/lib/opswalrus/cli.rb +143 -0
- data/lib/opswalrus/host.rb +177 -0
- data/lib/opswalrus/hosts_file.rb +55 -0
- data/lib/opswalrus/interaction_handlers.rb +53 -0
- data/lib/opswalrus/local_non_blocking_backend.rb +132 -0
- data/lib/opswalrus/local_pty_backend.rb +89 -0
- data/lib/opswalrus/operation_runner.rb +85 -0
- data/lib/opswalrus/ops_file.rb +235 -0
- data/lib/opswalrus/ops_file_script.rb +472 -0
- data/lib/opswalrus/package_file.rb +102 -0
- data/lib/opswalrus/runtime_environment.rb +258 -0
- data/lib/opswalrus/sshkit_ext.rb +51 -0
- data/lib/opswalrus/traversable.rb +15 -0
- data/lib/opswalrus/version.rb +3 -0
- data/lib/opswalrus/walrus_lang.rb +83 -0
- data/lib/opswalrus/zip.rb +57 -0
- data/lib/opswalrus.rb +10 -0
- data/opswalrus.gemspec +45 -0
- data/sig/opswalrus.rbs +4 -0
- metadata +178 -0
@@ -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
|