opswalrus 1.0.6 → 1.0.7
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/build.ops +20 -0
- data/lib/opswalrus/app.rb +2 -15
- data/lib/opswalrus/host.rb +23 -10
- data/lib/opswalrus/interaction_handlers.rb +85 -0
- data/lib/opswalrus/local_pty_backend.rb +12 -2
- data/lib/opswalrus/ops_file_script.rb +23 -14
- data/lib/opswalrus/runtime_environment.rb +19 -0
- data/lib/opswalrus/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b0c1c00f88ff00a4f7eb735a634ea35e1e8818b44fffdccea7a2347bac78e199
|
4
|
+
data.tar.gz: fe54254076c753cfeaf26906fa0f7475bd11807c0ac70d4067da03653b0f774c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 28244a51a465941731663e9db3901049d36d1fa385a16b4c5ed9254c31fa62ca44012c2d776ef5b6d1d06906c012802b1352e166d72d28b3ec0aa6f9e27073f5
|
7
|
+
data.tar.gz: 39ccb70ca9dc5b2ecf67b1ab29adb7ccc79cb43ad57d256be0deb68b49909bc4c5cd4ddc2871e68aeeb374ed3d0202c365094536e7933f61c3ecf700a2178076
|
data/Gemfile.lock
CHANGED
data/build.ops
CHANGED
@@ -7,6 +7,8 @@ imports:
|
|
7
7
|
|
8
8
|
version = params.version
|
9
9
|
|
10
|
+
exit 1, "version parameter must be specified" unless version
|
11
|
+
|
10
12
|
template = <<TEMPLATE
|
11
13
|
module OpsWalrus
|
12
14
|
VERSION = "{{ version }}"
|
@@ -17,4 +19,22 @@ puts "Write version.rb for version #{version}"
|
|
17
19
|
core.template.write(path: "./lib/opswalrus/version.rb", template: template, variables: {version: version})
|
18
20
|
|
19
21
|
sh("Build gem") { 'gem build opswalrus.gemspec' }
|
22
|
+
bw_status_output = sh("Check whether Bitwarden is locked or not") { 'bw status' }
|
23
|
+
# the status command currently exhibits an error in which it emits 'mac failed.' some number of times, so we need to filter that out
|
24
|
+
# see:
|
25
|
+
# - https://community.bitwarden.com/t/what-does-mac-failed-mean-exactly/29208
|
26
|
+
# - https://github.com/bitwarden/cli/issues/88
|
27
|
+
# - https://github.com/vwxyzjn/portwarden/issues/22
|
28
|
+
# ❯ bw status
|
29
|
+
# mac failed.
|
30
|
+
# {"serverUrl":"...","lastSync":"2023-08-17T19:14:09.384Z","userEmail":"...","userId":"...","status":"locked"}
|
31
|
+
bw_status_output = bw_status_output.gsub('mac failed.', '').strip
|
32
|
+
bw_status_json = bw_status_output.parse_json
|
33
|
+
|
34
|
+
if bw_status_json['status'] != 'unlocked'
|
35
|
+
exit 0, "Bitwarden is not unlocked. Please unlock bitwarden with: bw unlock"
|
36
|
+
end
|
37
|
+
|
38
|
+
totp = sh("Get Rubygems OTP") { 'bw get totp Rubygems' }
|
39
|
+
sh("Push gem", input: {"You have enabled multi-factor authentication. Please enter OTP code." => "#{totp}\n"}) { 'gem push opswalrus-{{ version }}.gem' }
|
20
40
|
sh("Build docker image") { 'docker build -t opswalrus/ops:{{ version }} .' }
|
data/lib/opswalrus/app.rb
CHANGED
@@ -7,6 +7,7 @@ require "socket"
|
|
7
7
|
require "stringio"
|
8
8
|
require "yaml"
|
9
9
|
require "pathname"
|
10
|
+
require_relative "patches"
|
10
11
|
require_relative "git"
|
11
12
|
require_relative "host"
|
12
13
|
require_relative "hosts_file"
|
@@ -14,21 +15,6 @@ require_relative "operation_runner"
|
|
14
15
|
require_relative "bundler"
|
15
16
|
require_relative "package_file"
|
16
17
|
|
17
|
-
class String
|
18
|
-
def escape_single_quotes
|
19
|
-
gsub("'"){"\\'"}
|
20
|
-
end
|
21
|
-
|
22
|
-
def to_pathname
|
23
|
-
Pathname.new(self)
|
24
|
-
end
|
25
|
-
end
|
26
|
-
|
27
|
-
class Pathname
|
28
|
-
def to_pathname
|
29
|
-
self
|
30
|
-
end
|
31
|
-
end
|
32
18
|
|
33
19
|
module OpsWalrus
|
34
20
|
class Error < StandardError
|
@@ -131,6 +117,7 @@ module OpsWalrus
|
|
131
117
|
def prompt_sudo_password
|
132
118
|
password = IO::console.getpass(LOCAL_SUDO_PASSWORD_PROMPT)
|
133
119
|
set_sudo_password(password)
|
120
|
+
# puts "sudo password = |#{password}|"
|
134
121
|
nil
|
135
122
|
end
|
136
123
|
|
data/lib/opswalrus/host.rb
CHANGED
@@ -7,18 +7,18 @@ module OpsWalrus
|
|
7
7
|
|
8
8
|
module HostDSL
|
9
9
|
# returns the stdout from the command
|
10
|
-
def sh(desc_or_cmd = nil, cmd = nil,
|
11
|
-
out, err, status = *shell!(desc_or_cmd, cmd, block,
|
10
|
+
def sh(desc_or_cmd = nil, cmd = nil, input: nil, &block)
|
11
|
+
out, err, status = *shell!(desc_or_cmd, cmd, block, input: input)
|
12
12
|
out
|
13
13
|
end
|
14
14
|
|
15
15
|
# returns the tuple: [stdout, stderr, exit_status]
|
16
|
-
def shell(desc_or_cmd = nil, cmd = nil,
|
17
|
-
shell!(desc_or_cmd, cmd, block,
|
16
|
+
def shell(desc_or_cmd = nil, cmd = nil, input: nil, &block)
|
17
|
+
shell!(desc_or_cmd, cmd, block, input: input)
|
18
18
|
end
|
19
19
|
|
20
20
|
# returns the tuple: [stdout, stderr, exit_status]
|
21
|
-
def shell!(desc_or_cmd = nil, cmd = nil, block = nil,
|
21
|
+
def shell!(desc_or_cmd = nil, cmd = nil, block = nil, input: nil)
|
22
22
|
# description = nil
|
23
23
|
|
24
24
|
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
|
@@ -45,7 +45,7 @@ module OpsWalrus
|
|
45
45
|
# puts "shell: #{cmd.inspect}"
|
46
46
|
# puts "sudo_password: #{sudo_password}"
|
47
47
|
|
48
|
-
sshkit_cmd = execute_cmd(cmd)
|
48
|
+
sshkit_cmd = execute_cmd(cmd, input: input)
|
49
49
|
|
50
50
|
[sshkit_cmd.full_stdout, sshkit_cmd.full_stderr, sshkit_cmd.exit_status]
|
51
51
|
end
|
@@ -203,6 +203,10 @@ module OpsWalrus
|
|
203
203
|
})
|
204
204
|
end
|
205
205
|
|
206
|
+
def set_runtime_env(runtime_env)
|
207
|
+
@runtime_env = runtime_env
|
208
|
+
end
|
209
|
+
|
206
210
|
def set_ssh_session_connection(sshkit_backend)
|
207
211
|
@sshkit_backend = sshkit_backend
|
208
212
|
end
|
@@ -212,18 +216,27 @@ module OpsWalrus
|
|
212
216
|
end
|
213
217
|
|
214
218
|
def clear_ssh_session
|
219
|
+
@runtime_env = nil
|
215
220
|
@sshkit_backend = nil
|
216
221
|
@tmp_bundle_root_dir = nil
|
217
222
|
end
|
218
223
|
|
219
|
-
def execute(*args)
|
224
|
+
def execute(*args, input: nil)
|
220
225
|
# puts "interaction handler responds with: #{ssh_password}"
|
221
|
-
@sshkit_backend.capture(*args, interaction_handler: SudoPasswordMapper.new(ssh_password).interaction_handler, verbosity: :info)
|
226
|
+
# @sshkit_backend.capture(*args, interaction_handler: SudoPasswordMapper.new(ssh_password).interaction_handler, verbosity: :info)
|
222
227
|
# @sshkit_backend.capture(*args, interaction_handler: SudoPromptInteractionHandler.new, verbosity: :info)
|
228
|
+
|
229
|
+
@runtime_env.handle_input(input, ssh_password) do |interaction_handler|
|
230
|
+
@sshkit_backend.capture(*args, interaction_handler: interaction_handler, verbosity: :info)
|
231
|
+
end
|
232
|
+
|
223
233
|
end
|
224
234
|
|
225
|
-
def execute_cmd(*args)
|
226
|
-
@sshkit_backend.execute_cmd(*args, interaction_handler: SudoPasswordMapper.new(ssh_password).interaction_handler, verbosity: :info)
|
235
|
+
def execute_cmd(*args, input: nil)
|
236
|
+
# @sshkit_backend.execute_cmd(*args, interaction_handler: SudoPasswordMapper.new(ssh_password).interaction_handler, verbosity: :info)
|
237
|
+
@runtime_env.handle_input(input, ssh_password) do |interaction_handler|
|
238
|
+
@sshkit_backend.execute_cmd(*args, interaction_handler: interaction_handler, verbosity: :info)
|
239
|
+
end
|
227
240
|
end
|
228
241
|
|
229
242
|
def upload(local_path_or_io, remote_path)
|
@@ -2,6 +2,91 @@ require 'sshkit'
|
|
2
2
|
|
3
3
|
module OpsWalrus
|
4
4
|
|
5
|
+
class ScopedMappingInteractionHandler
|
6
|
+
attr_accessor :input_mappings # Hash[ String | Regex => String ]
|
7
|
+
|
8
|
+
def initialize(mapping, log_level = nil)
|
9
|
+
@log_level = log_level
|
10
|
+
@input_mappings = mapping
|
11
|
+
end
|
12
|
+
|
13
|
+
# temporarily adds a sudo password mapping to the interaction handler while the given block is being evaluated
|
14
|
+
# when the given block returns, then the temporary mapping is removed from the interaction handler
|
15
|
+
# def with_sudo_password(password, &block)
|
16
|
+
# with_mapping({
|
17
|
+
# /\[sudo\] password for .*?:\s*/ => "#{password}\n",
|
18
|
+
# App::LOCAL_SUDO_PASSWORD_PROMPT => "#{password}\n",
|
19
|
+
# # /\s+/ => nil, # unnecessary
|
20
|
+
# }, &block)
|
21
|
+
# end
|
22
|
+
|
23
|
+
# sudo_password : String
|
24
|
+
def mapping_for_sudo_password(sudo_password)
|
25
|
+
{
|
26
|
+
/\[sudo\] password for .*?:\s*/ => "#{sudo_password}\n",
|
27
|
+
App::LOCAL_SUDO_PASSWORD_PROMPT => "#{sudo_password}\n",
|
28
|
+
# /\s+/ => nil, # unnecessary
|
29
|
+
}
|
30
|
+
end
|
31
|
+
|
32
|
+
# temporarily adds the specified input mapping to the interaction handler while the given block is being evaluated
|
33
|
+
# when the given block returns, then the temporary mapping is removed from the interaction handler
|
34
|
+
#
|
35
|
+
# mapping : Hash[ String | Regex => String ]
|
36
|
+
def with_mapping(mapping, sudo_password = nil)
|
37
|
+
mapping ||= {}
|
38
|
+
|
39
|
+
raise ArgumentError.new("mapping must be a Hash") unless mapping.is_a?(Hash)
|
40
|
+
|
41
|
+
if sudo_password
|
42
|
+
mapping.merge!(mapping_for_sudo_password(sudo_password))
|
43
|
+
end
|
44
|
+
|
45
|
+
if mapping.empty?
|
46
|
+
yield self
|
47
|
+
else
|
48
|
+
yield ScopedMappingInteractionHandler.new(@input_mappings.merge(mapping), @log_level)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
# adds the specified input mapping to the interaction handler
|
53
|
+
#
|
54
|
+
# mapping : Hash[ String | Regex => String ]
|
55
|
+
def add_mapping(mapping)
|
56
|
+
@input_mappings.merge!(mapping)
|
57
|
+
end
|
58
|
+
|
59
|
+
def on_data(_command, stream_name, data, channel)
|
60
|
+
log("Looking up response for #{stream_name} message #{data.inspect}")
|
61
|
+
|
62
|
+
response_data = begin
|
63
|
+
first_matching_key_value_pair = @input_mappings.find {|k, _v| k === data }
|
64
|
+
first_matching_key_value_pair&.last
|
65
|
+
end
|
66
|
+
|
67
|
+
if response_data.nil?
|
68
|
+
log("Unable to find interaction handler mapping for #{stream_name}: #{data.inspect} so no response was sent")
|
69
|
+
else
|
70
|
+
log("Sending #{response_data.inspect}")
|
71
|
+
if channel.respond_to?(:send_data) # Net SSH Channel
|
72
|
+
channel.send_data(response_data)
|
73
|
+
elsif channel.respond_to?(:write) # Local IO
|
74
|
+
channel.write(response_data)
|
75
|
+
else
|
76
|
+
raise "Unable to write response data to channel #{channel.inspect} - does not support '#send_data' or '#write'"
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
private
|
82
|
+
|
83
|
+
def log(message)
|
84
|
+
# puts message
|
85
|
+
SSHKit.config.output.send(@log_level, message) unless @log_level.nil?
|
86
|
+
end
|
87
|
+
|
88
|
+
end
|
89
|
+
|
5
90
|
class PasswdInteractionHandler
|
6
91
|
def on_data(command, stream_name, data, channel)
|
7
92
|
# puts data
|
@@ -16,12 +16,16 @@ module SSHKit
|
|
16
16
|
cmd.started = Time.now
|
17
17
|
PTY.spawn(cmd.to_command) do |stdout, stdin, pid|
|
18
18
|
stdout_thread = Thread.new do
|
19
|
+
# debug_log = StringIO.new
|
19
20
|
buffer = ""
|
20
21
|
partial_buffer = ""
|
21
22
|
while !stdout.closed?
|
23
|
+
# debug_log.puts "!!!\nbuffer=#{buffer}|EOL|\npartial=#{partial_buffer}|EOL|"
|
22
24
|
# puts "9" * 80
|
23
25
|
begin
|
24
|
-
|
26
|
+
# partial_buffer = ""
|
27
|
+
# stdout.read_nonblock(4096, partial_buffer)
|
28
|
+
partial_buffer = stdout.read_nonblock(4096)
|
25
29
|
buffer << partial_buffer
|
26
30
|
# puts "nonblocking1. buffer=#{buffer} partial_buffer=#{partial_buffer}"
|
27
31
|
buffer = handle_data_for_stdout(output, cmd, buffer, stdin, false)
|
@@ -48,6 +52,10 @@ module SSHKit
|
|
48
52
|
end
|
49
53
|
end
|
50
54
|
# puts "end!"
|
55
|
+
# debug_log.puts "!!!\nbuffer=#{buffer}|EOL|\npartial=#{partial_buffer}|EOL|"
|
56
|
+
|
57
|
+
# puts "*" * 80
|
58
|
+
# puts debug_log.string
|
51
59
|
|
52
60
|
end
|
53
61
|
stdout_thread.join
|
@@ -59,12 +67,14 @@ module SSHKit
|
|
59
67
|
|
60
68
|
# returns [complete lines, new buffer]
|
61
69
|
def split_buffer(buffer)
|
62
|
-
lines = buffer.split(/(\r\n)
|
70
|
+
lines = buffer.split(/(\r\n)|\r|\n/)
|
63
71
|
buffer = lines.pop
|
64
72
|
[lines, buffer]
|
65
73
|
end
|
66
74
|
|
67
75
|
def handle_data_for_stdout(output, cmd, buffer, stdin, is_blocked)
|
76
|
+
# puts "handling data for stdout: #{buffer}"
|
77
|
+
|
68
78
|
# we're blocked on reading, so let's process the buffer
|
69
79
|
lines, buffer = split_buffer(buffer)
|
70
80
|
lines.each do |line|
|
@@ -121,13 +121,15 @@ module OpsWalrus
|
|
121
121
|
# bootstrap_shell_script = BootstrapLinuxHostShellScript
|
122
122
|
# on sshkit_hosts do |sshkit_host|
|
123
123
|
SSHKit::Coordinator.new(sshkit_hosts).each(in: kwargs[:in] || :parallel) do |sshkit_host|
|
124
|
-
host = sshkit_host_to_ops_host_map[sshkit_host]
|
125
124
|
|
125
|
+
# in this context, self is an instance of one of the subclasses of SSHKit::Backend::Abstract, e.g. SSHKit::Backend::Netssh
|
126
|
+
|
127
|
+
host = sshkit_host_to_ops_host_map[sshkit_host]
|
126
128
|
# puts "#{host.alias} / #{host}:"
|
127
129
|
|
128
130
|
begin
|
129
|
-
|
130
|
-
host.set_ssh_session_connection(self)
|
131
|
+
host.set_runtime_env(runtime_env)
|
132
|
+
host.set_ssh_session_connection(self) # self is an instance of one of the subclasses of SSHKit::Backend::Abstract, e.g. SSHKit::Backend::Netssh
|
131
133
|
|
132
134
|
# copy over bootstrap shell script
|
133
135
|
# io = StringIO.new(bootstrap_shell_script)
|
@@ -199,11 +201,14 @@ module OpsWalrus
|
|
199
201
|
@runtime_env.app.inventory(tags)
|
200
202
|
end
|
201
203
|
|
202
|
-
def exit(exit_status)
|
204
|
+
def exit(exit_status, message = nil)
|
205
|
+
if message
|
206
|
+
puts message
|
207
|
+
end
|
203
208
|
result = if exit_status == 0
|
204
|
-
Success.new(nil)
|
209
|
+
Invocation::Success.new(nil)
|
205
210
|
else
|
206
|
-
Error.new(nil, exit_status)
|
211
|
+
Invocation::Error.new(nil, exit_status)
|
207
212
|
end
|
208
213
|
throw :exit_now, result
|
209
214
|
end
|
@@ -240,18 +245,18 @@ module OpsWalrus
|
|
240
245
|
end
|
241
246
|
|
242
247
|
# returns the stdout from the command
|
243
|
-
def sh(desc_or_cmd = nil, cmd = nil,
|
244
|
-
out, err, status = *shell!(desc_or_cmd, cmd, block,
|
248
|
+
def sh(desc_or_cmd = nil, cmd = nil, input: nil, &block)
|
249
|
+
out, err, status = *shell!(desc_or_cmd, cmd, block, input: input)
|
245
250
|
out
|
246
251
|
end
|
247
252
|
|
248
253
|
# returns the tuple: [stdout, stderr, exit_status]
|
249
|
-
def shell(desc_or_cmd = nil, cmd = nil,
|
250
|
-
shell!(desc_or_cmd, cmd, block,
|
254
|
+
def shell(desc_or_cmd = nil, cmd = nil, input: nil, &block)
|
255
|
+
shell!(desc_or_cmd, cmd, block, input: input)
|
251
256
|
end
|
252
257
|
|
253
258
|
# returns the tuple: [stdout, stderr, exit_status]
|
254
|
-
def shell!(desc_or_cmd = nil, cmd = nil, block = nil,
|
259
|
+
def shell!(desc_or_cmd = nil, cmd = nil, block = nil, input: nil)
|
255
260
|
# description = nil
|
256
261
|
|
257
262
|
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
|
@@ -272,8 +277,8 @@ module OpsWalrus
|
|
272
277
|
|
273
278
|
return unless cmd && !cmd.strip.empty?
|
274
279
|
|
275
|
-
sudo_password = @runtime_env.sudo_password
|
276
|
-
sudo_password &&= sudo_password.gsub(/\n+$/,'') # remove trailing newlines from sudo_password
|
280
|
+
# sudo_password = @runtime_env.sudo_password
|
281
|
+
# sudo_password &&= sudo_password.gsub(/\n+$/,'') # remove trailing newlines from sudo_password
|
277
282
|
|
278
283
|
# puts "shell: #{cmd}"
|
279
284
|
# puts "shell: #{cmd.inspect}"
|
@@ -281,10 +286,14 @@ module OpsWalrus
|
|
281
286
|
|
282
287
|
# sshkit_cmd = SSHKit::Backend::LocalNonBlocking.new {
|
283
288
|
# sshkit_cmd = SSHKit::Backend::LocalPty.new {
|
284
|
-
sshkit_cmd = backend.execute_cmd(cmd, interaction_handler: SudoPasswordMapper.new(sudo_password).interaction_handler, verbosity: :info)
|
289
|
+
# sshkit_cmd = backend.execute_cmd(cmd, interaction_handler: SudoPasswordMapper.new(sudo_password).interaction_handler, verbosity: :info)
|
285
290
|
# execute_cmd(cmd, interaction_handler: SudoPromptInteractionHandler.new, verbosity: :info)
|
286
291
|
# }.run
|
287
292
|
|
293
|
+
sshkit_cmd = @runtime_env.handle_input(input) do |interaction_handler|
|
294
|
+
backend.execute_cmd(cmd, interaction_handler: interaction_handler, verbosity: :info)
|
295
|
+
end
|
296
|
+
|
288
297
|
[sshkit_cmd.full_stdout, sshkit_cmd.full_stderr, sshkit_cmd.exit_status]
|
289
298
|
end
|
290
299
|
|
@@ -3,6 +3,7 @@ require 'shellwords'
|
|
3
3
|
require 'socket'
|
4
4
|
require 'sshkit'
|
5
5
|
|
6
|
+
require_relative 'interaction_handlers'
|
6
7
|
require_relative 'traversable'
|
7
8
|
require_relative 'walrus_lang'
|
8
9
|
|
@@ -196,9 +197,27 @@ module OpsWalrus
|
|
196
197
|
@bundle_load_path = LoadPath.new(self, @app.bundle_dir)
|
197
198
|
@app_load_path = LoadPath.new(self, @app.pwd)
|
198
199
|
|
200
|
+
@interaction_handler = ScopedMappingInteractionHandler.new({
|
201
|
+
/\[sudo\] password for .*?:\s*/ => "#{sudo_password}\n",
|
202
|
+
})
|
203
|
+
|
199
204
|
configure_sshkit
|
200
205
|
end
|
201
206
|
|
207
|
+
# def with_sudo_password(password, &block)
|
208
|
+
# @interaction_handler.with_sudo_password(password, &block)
|
209
|
+
# end
|
210
|
+
|
211
|
+
# input_mapping : Hash[ String | Regex => String ]
|
212
|
+
# sudo_password : String
|
213
|
+
def handle_input(input_mapping, sudo_password = nil, &block)
|
214
|
+
@interaction_handler.with_mapping(input_mapping, sudo_password, &block)
|
215
|
+
end
|
216
|
+
|
217
|
+
# def handle_input_with_sudo_password(input_mapping, password, &block)
|
218
|
+
# @interaction_handler.with_mapping(input_mapping, password, &block)
|
219
|
+
# end
|
220
|
+
|
202
221
|
# configure sshkit globally
|
203
222
|
def configure_sshkit
|
204
223
|
SSHKit.config.use_format :blackhole
|
data/lib/opswalrus/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: opswalrus
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.0.
|
4
|
+
version: 1.0.7
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- David Ellis
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-08-
|
11
|
+
date: 2023-08-18 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: citrus
|