smart_proxy_remote_execution_ssh 0.8.0 → 0.10.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 +4 -4
- data/lib/smart_proxy_remote_execution_ssh/actions/pull_script.rb +23 -18
- data/lib/smart_proxy_remote_execution_ssh/api.rb +2 -0
- data/lib/smart_proxy_remote_execution_ssh/command_logging.rb +1 -1
- data/lib/smart_proxy_remote_execution_ssh/job_storage.rb +4 -2
- data/lib/smart_proxy_remote_execution_ssh/mqtt/dispatcher.rb +169 -0
- data/lib/smart_proxy_remote_execution_ssh/mqtt.rb +23 -0
- data/lib/smart_proxy_remote_execution_ssh/multiplexed_ssh_connection.rb +4 -4
- data/lib/smart_proxy_remote_execution_ssh/plugin.rb +9 -1
- data/lib/smart_proxy_remote_execution_ssh/runners/polling_script_runner.rb +0 -1
- data/lib/smart_proxy_remote_execution_ssh/version.rb +1 -1
- data/settings.d/remote_execution_ssh.yml.example +4 -0
- metadata +5 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: '048f8694430967060fae6bd7790fe56930b5c2bac37f3bc26e7b13db69081da6'
|
4
|
+
data.tar.gz: 5a3b1e344a47aa4c97dcce97f43dc9e424d398f79c67a6f1e066d670758c99f0
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a4bc90b3dda6d190c8f94aeb6731a3a6679788ea4f950e1bb4196c1d5da4630b170fe152e9c96a5e4cb7dcd25ba1d6371292e52b4059773759c1fbe8461bd347
|
7
|
+
data.tar.gz: f4849b32f7529cbb29d127ae1c4c9fd9642b0700c834b1e3eebf59b5d9cfca282fca674188a734a37b36d45ff3e423096aa3512ceae118aef7ff831cb1600a38
|
@@ -5,6 +5,7 @@ require 'time'
|
|
5
5
|
module Proxy::RemoteExecution::Ssh::Actions
|
6
6
|
class PullScript < Proxy::Dynflow::Action::Runner
|
7
7
|
JobDelivered = Class.new
|
8
|
+
PickupTimeout = Class.new
|
8
9
|
|
9
10
|
execution_plan_hooks.use :cleanup, :on => :stopped
|
10
11
|
|
@@ -17,9 +18,14 @@ module Proxy::RemoteExecution::Ssh::Actions
|
|
17
18
|
if event == JobDelivered
|
18
19
|
output[:state] = :delivered
|
19
20
|
suspend
|
21
|
+
elsif event == PickupTimeout
|
22
|
+
process_pickup_timeout
|
20
23
|
else
|
21
24
|
super
|
22
25
|
end
|
26
|
+
rescue => e
|
27
|
+
action_logger.error(e)
|
28
|
+
process_update(Proxy::Dynflow::Runner::Update.encode_exception('Proxy error', e))
|
23
29
|
end
|
24
30
|
|
25
31
|
def init_run
|
@@ -27,9 +33,12 @@ module Proxy::RemoteExecution::Ssh::Actions
|
|
27
33
|
::Proxy::Dynflow::OtpManager.generate_otp(execution_plan_id)
|
28
34
|
end
|
29
35
|
|
30
|
-
input[:
|
36
|
+
plan_event(PickupTimeout, input[:time_to_pickup], optional: true) if input[:time_to_pickup]
|
37
|
+
|
38
|
+
input[:job_uuid] = job_storage.store_job(host_name, execution_plan_id, run_step_id, input[:script].tr("\r", ''), effective_user: input[:effective_user])
|
31
39
|
output[:state] = :ready_for_pickup
|
32
40
|
output[:result] = []
|
41
|
+
|
33
42
|
mqtt_start(otp_password) if input[:with_mqtt]
|
34
43
|
suspend
|
35
44
|
end
|
@@ -37,6 +46,7 @@ module Proxy::RemoteExecution::Ssh::Actions
|
|
37
46
|
def cleanup(_plan = nil)
|
38
47
|
job_storage.drop_job(execution_plan_id, run_step_id)
|
39
48
|
Proxy::Dynflow::OtpManager.passwords.delete(execution_plan_id)
|
49
|
+
Proxy::RemoteExecution::Ssh::MQTT::Dispatcher.instance.done(input[:job_uuid])
|
40
50
|
end
|
41
51
|
|
42
52
|
def process_external_event(event)
|
@@ -103,9 +113,11 @@ module Proxy::RemoteExecution::Ssh::Actions
|
|
103
113
|
'username': execution_plan_id,
|
104
114
|
'password': otp_password,
|
105
115
|
'return_url': "#{input[:proxy_url]}/ssh/jobs/#{input[:job_uuid]}/update",
|
116
|
+
'version': 'v1',
|
117
|
+
'effective_user': input[:effective_user]
|
106
118
|
},
|
107
119
|
)
|
108
|
-
|
120
|
+
Proxy::RemoteExecution::Ssh::MQTT::Dispatcher.instance.new(input[:job_uuid], mqtt_topic, payload)
|
109
121
|
output[:state] = :notified
|
110
122
|
end
|
111
123
|
|
@@ -121,18 +133,7 @@ module Proxy::RemoteExecution::Ssh::Actions
|
|
121
133
|
end
|
122
134
|
|
123
135
|
def mqtt_notify(payload)
|
124
|
-
|
125
|
-
c.publish(mqtt_topic, JSON.dump(payload), false, 1)
|
126
|
-
end
|
127
|
-
end
|
128
|
-
|
129
|
-
def with_mqtt_client(&block)
|
130
|
-
MQTT::Client.connect(settings.mqtt_broker, settings.mqtt_port,
|
131
|
-
:ssl => settings.mqtt_tls,
|
132
|
-
:cert_file => ::Proxy::SETTINGS.foreman_ssl_cert || ::Proxy::SETTINGS.ssl_certificate,
|
133
|
-
:key_file => ::Proxy::SETTINGS.foreman_ssl_key || ::Proxy::SETTINGS.ssl_private_key,
|
134
|
-
:ca_file => ::Proxy::SETTINGS.foreman_ssl_ca || ::Proxy::SETTINGS.ssl_ca_file,
|
135
|
-
&block)
|
136
|
+
Proxy::RemoteExecution::Ssh::MQTT.publish(mqtt_topic, JSON.dump(payload))
|
136
137
|
end
|
137
138
|
|
138
139
|
def host_name
|
@@ -147,10 +148,6 @@ module Proxy::RemoteExecution::Ssh::Actions
|
|
147
148
|
"yggdrasil/#{host_name}/data/in"
|
148
149
|
end
|
149
150
|
|
150
|
-
def settings
|
151
|
-
Proxy::RemoteExecution::Ssh::Plugin.settings
|
152
|
-
end
|
153
|
-
|
154
151
|
def job_storage
|
155
152
|
Proxy::RemoteExecution::Ssh.job_storage
|
156
153
|
end
|
@@ -164,5 +161,13 @@ module Proxy::RemoteExecution::Ssh::Actions
|
|
164
161
|
directive: 'foreman'
|
165
162
|
}
|
166
163
|
end
|
164
|
+
|
165
|
+
def process_pickup_timeout
|
166
|
+
if output[:state] != :delivered
|
167
|
+
raise "The job was not picked up in time"
|
168
|
+
else
|
169
|
+
suspend
|
170
|
+
end
|
171
|
+
end
|
167
172
|
end
|
168
173
|
end
|
@@ -64,7 +64,9 @@ module Proxy::RemoteExecution
|
|
64
64
|
do_authorize_with_ssl_client
|
65
65
|
|
66
66
|
with_authorized_job(job_uuid) do |job_record|
|
67
|
+
Proxy::RemoteExecution::Ssh::MQTT::Dispatcher.instance.running(job_record[:uuid])
|
67
68
|
notify_job(job_record, Actions::PullScript::JobDelivered)
|
69
|
+
response.headers['X-Foreman-Effective-User'] = job_record[:effective_user]
|
68
70
|
job_record[:job]
|
69
71
|
end
|
70
72
|
end
|
@@ -14,7 +14,7 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
14
14
|
logger.debug(line.chomp) if user_method.nil? || !user_method.filter_password?(line)
|
15
15
|
user_method.on_data(data, pm.stdin) if user_method
|
16
16
|
end
|
17
|
-
|
17
|
+
data
|
18
18
|
end
|
19
19
|
pm.on_stdout(&callback)
|
20
20
|
pm.on_stderr(&callback)
|
@@ -11,6 +11,7 @@ module Proxy::RemoteExecution::Ssh
|
|
11
11
|
String :hostname, null: false, index: true
|
12
12
|
String :execution_plan_uuid, fixed: true, size: 36, null: false, index: true
|
13
13
|
Integer :run_step_id, null: false
|
14
|
+
String :effective_user
|
14
15
|
String :job, text: true
|
15
16
|
end
|
16
17
|
end
|
@@ -24,13 +25,14 @@ module Proxy::RemoteExecution::Ssh
|
|
24
25
|
.select_map(:uuid)
|
25
26
|
end
|
26
27
|
|
27
|
-
def store_job(hostname, execution_plan_uuid, run_step_id, job, uuid: SecureRandom.uuid, timestamp: Time.now.utc)
|
28
|
+
def store_job(hostname, execution_plan_uuid, run_step_id, job, uuid: SecureRandom.uuid, timestamp: Time.now.utc, effective_user: nil)
|
28
29
|
jobs.insert(timestamp: timestamp,
|
29
30
|
uuid: uuid,
|
30
31
|
hostname: hostname,
|
31
32
|
execution_plan_uuid: execution_plan_uuid,
|
32
33
|
run_step_id: run_step_id,
|
33
|
-
job: job
|
34
|
+
job: job,
|
35
|
+
effective_user: effective_user)
|
34
36
|
uuid
|
35
37
|
end
|
36
38
|
|
@@ -0,0 +1,169 @@
|
|
1
|
+
require 'concurrent'
|
2
|
+
require 'mqtt'
|
3
|
+
|
4
|
+
class Proxy::RemoteExecution::Ssh::MQTT
|
5
|
+
class Dispatcher
|
6
|
+
include Singleton
|
7
|
+
|
8
|
+
attr_reader :reference
|
9
|
+
def initialize
|
10
|
+
limit = Proxy::RemoteExecution::Ssh::Plugin.settings[:mqtt_rate_limit]
|
11
|
+
@reference = DispatcherActor.spawn('MQTT dispatcher',
|
12
|
+
Proxy::Dynflow::Core.world.clock,
|
13
|
+
limit)
|
14
|
+
end
|
15
|
+
|
16
|
+
def new(uuid, topic, payload)
|
17
|
+
reference.tell([:new, uuid, topic, payload])
|
18
|
+
end
|
19
|
+
|
20
|
+
def running(uuid)
|
21
|
+
reference.tell([:running, uuid])
|
22
|
+
end
|
23
|
+
|
24
|
+
def resend(uuid)
|
25
|
+
reference.tell([:resend, uuid])
|
26
|
+
end
|
27
|
+
|
28
|
+
def done(uuid)
|
29
|
+
reference.tell([:done, uuid])
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
class DispatcherActor < Concurrent::Actor::RestartingContext
|
34
|
+
JobDefinition = Struct.new :uuid, :topic, :payload
|
35
|
+
|
36
|
+
class Tracker
|
37
|
+
def initialize(limit, clock)
|
38
|
+
@clock = clock
|
39
|
+
@limit = limit
|
40
|
+
@jobs = {}
|
41
|
+
@pending = []
|
42
|
+
@running = Set.new
|
43
|
+
@hot = Set.new
|
44
|
+
@cold = Set.new
|
45
|
+
end
|
46
|
+
|
47
|
+
def new(uuid, topic, payload)
|
48
|
+
@jobs[uuid] = JobDefinition.new(uuid, topic, payload)
|
49
|
+
@pending << uuid
|
50
|
+
dispatch_pending
|
51
|
+
end
|
52
|
+
|
53
|
+
def running(uuid)
|
54
|
+
[@pending, @hot, @cold].each { |source| source.delete(uuid) }
|
55
|
+
@running << uuid
|
56
|
+
end
|
57
|
+
|
58
|
+
def resend(uuid)
|
59
|
+
return unless @jobs[uuid]
|
60
|
+
|
61
|
+
@pending << uuid
|
62
|
+
dispatch_pending
|
63
|
+
end
|
64
|
+
|
65
|
+
def done(uuid)
|
66
|
+
@jobs.delete(uuid)
|
67
|
+
[@pending, @running, @hot, @cold].each do |source|
|
68
|
+
source.delete(uuid)
|
69
|
+
end
|
70
|
+
dispatch_pending
|
71
|
+
end
|
72
|
+
|
73
|
+
def needs_processing?
|
74
|
+
pending_count.positive? || @hot.any? || @cold.any?
|
75
|
+
end
|
76
|
+
|
77
|
+
def pending_count
|
78
|
+
pending = @pending.count
|
79
|
+
return pending if @limit.nil?
|
80
|
+
|
81
|
+
running = [@running, @hot, @cold].map(&:count).sum
|
82
|
+
capacity = @limit - running
|
83
|
+
pending > capacity ? capacity : pending
|
84
|
+
end
|
85
|
+
|
86
|
+
def dispatch_pending
|
87
|
+
pending_count.times do
|
88
|
+
mqtt_notify(@pending.first)
|
89
|
+
@hot << @pending.shift
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def process
|
94
|
+
@cold.each { |uuid| schedule_resend(uuid) }
|
95
|
+
@cold = @hot
|
96
|
+
@hot = Set.new
|
97
|
+
|
98
|
+
dispatch_pending
|
99
|
+
end
|
100
|
+
|
101
|
+
def mqtt_notify(uuid)
|
102
|
+
job = @jobs[uuid]
|
103
|
+
return if job.nil?
|
104
|
+
|
105
|
+
Proxy::RemoteExecution::Ssh::MQTT.publish(job.topic, JSON.dump(job.payload))
|
106
|
+
end
|
107
|
+
|
108
|
+
def settings
|
109
|
+
Proxy::RemoteExecution::Ssh::Plugin.settings
|
110
|
+
end
|
111
|
+
|
112
|
+
def schedule_resend(uuid)
|
113
|
+
@clock.ping(Proxy::RemoteExecution::Ssh::MQTT::Dispatcher.instance, resend_interval, uuid, :resend)
|
114
|
+
end
|
115
|
+
|
116
|
+
def resend_interval
|
117
|
+
settings[:mqtt_resend_interval]
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
def initialize(clock, limit = nil)
|
122
|
+
@tracker = Tracker.new(limit, clock)
|
123
|
+
|
124
|
+
interval = Proxy::RemoteExecution::Ssh::Plugin.settings[:mqtt_ttl]
|
125
|
+
@timer = Concurrent::TimerTask.new(execution_interval: interval) do
|
126
|
+
reference.tell(:tick)
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
def on_message(message)
|
131
|
+
action, arg = message
|
132
|
+
# Enable the timer just in case anything in tracker raises an exception so
|
133
|
+
# we can continue
|
134
|
+
timer_on
|
135
|
+
case action
|
136
|
+
when :new
|
137
|
+
_, uuid, topic, payload = message
|
138
|
+
@tracker.new(uuid, topic, payload)
|
139
|
+
when :resend
|
140
|
+
@tracker.resend(arg)
|
141
|
+
when :running
|
142
|
+
@tracker.running(arg)
|
143
|
+
when :done
|
144
|
+
@tracker.done(arg)
|
145
|
+
when :tick
|
146
|
+
@tracker.process
|
147
|
+
end
|
148
|
+
timer_set(@tracker.needs_processing?)
|
149
|
+
end
|
150
|
+
|
151
|
+
def timer_set(on)
|
152
|
+
on ? timer_on : timer_off
|
153
|
+
end
|
154
|
+
|
155
|
+
def timer_on
|
156
|
+
@timer.execute
|
157
|
+
end
|
158
|
+
|
159
|
+
def timer_off
|
160
|
+
@timer.shutdown
|
161
|
+
end
|
162
|
+
|
163
|
+
# In case an exception is raised during processing, instruct concurrent-ruby
|
164
|
+
# to keep going without losing state
|
165
|
+
def behaviour_definition
|
166
|
+
Concurrent::Actor::Behaviour.restarting_behaviour_definition(:resume!)
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Proxy::RemoteExecution::Ssh
|
2
|
+
class MQTT
|
3
|
+
require 'smart_proxy_remote_execution_ssh/mqtt/dispatcher'
|
4
|
+
|
5
|
+
class << self
|
6
|
+
def publish(topic, payload, retain: false, qos: 1)
|
7
|
+
with_mqtt_client do |c|
|
8
|
+
c.publish(topic, payload, retain, qos)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
def with_mqtt_client(&block)
|
13
|
+
::MQTT::Client.connect(Plugin.settings.mqtt_broker,
|
14
|
+
Plugin.settings.mqtt_port,
|
15
|
+
:ssl => Plugin.settings.mqtt_tls,
|
16
|
+
:cert_file => ::Proxy::SETTINGS.foreman_ssl_cert || ::Proxy::SETTINGS.ssl_certificate,
|
17
|
+
:key_file => ::Proxy::SETTINGS.foreman_ssl_key || ::Proxy::SETTINGS.ssl_private_key,
|
18
|
+
:ca_file => ::Proxy::SETTINGS.foreman_ssl_ca || ::Proxy::SETTINGS.ssl_ca_file,
|
19
|
+
&block)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -32,7 +32,7 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
32
32
|
return [] unless @password
|
33
33
|
|
34
34
|
prompt = ['-P', @prompt] if @prompt
|
35
|
-
[{'SSHPASS' => SensitiveString.new(@password)}, '
|
35
|
+
[{'SSHPASS' => SensitiveString.new(@password)}, 'sshpass', '-e', prompt].compact
|
36
36
|
end
|
37
37
|
|
38
38
|
def ssh_options
|
@@ -93,7 +93,7 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
93
93
|
def command(cmd)
|
94
94
|
raise "Cannot build command to run over multiplexed connection without having an established connection" unless connected?
|
95
95
|
|
96
|
-
['
|
96
|
+
['ssh', reuse_ssh_options, cmd].flatten
|
97
97
|
end
|
98
98
|
|
99
99
|
private
|
@@ -103,7 +103,7 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
103
103
|
# does not close its stderr which trips up the process manager which
|
104
104
|
# expects all FDs to be closed
|
105
105
|
|
106
|
-
full_command = [method.ssh_command_prefix, '
|
106
|
+
full_command = [method.ssh_command_prefix, 'ssh', establish_ssh_options, method.ssh_options, @host, 'true'].flatten
|
107
107
|
log_command(full_command)
|
108
108
|
pm = Proxy::Dynflow::ProcessManager.new(full_command)
|
109
109
|
pm.start!
|
@@ -166,7 +166,7 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
166
166
|
end
|
167
167
|
|
168
168
|
def verify_key_passphrase
|
169
|
-
command = ['
|
169
|
+
command = ['ssh-keygen', '-y', '-f', File.expand_path(@client_private_key_file)]
|
170
170
|
log_command(command, label: "Checking if private key has passphrase")
|
171
171
|
pm = Proxy::Dynflow::ProcessManager.new(command)
|
172
172
|
pm.start!
|
@@ -25,7 +25,10 @@ module Proxy::RemoteExecution::Ssh
|
|
25
25
|
# :mqtt_broker => nil,
|
26
26
|
# :mqtt_port => nil,
|
27
27
|
# :mqtt_tls => nil,
|
28
|
-
:
|
28
|
+
# :mqtt_rate_limit => nil
|
29
|
+
:mode => :ssh,
|
30
|
+
:mqtt_resend_interval => 900,
|
31
|
+
:mqtt_ttl => 5
|
29
32
|
|
30
33
|
capability(proc { 'cockpit' if settings.cockpit_integration })
|
31
34
|
|
@@ -45,6 +48,11 @@ module Proxy::RemoteExecution::Ssh
|
|
45
48
|
Proxy::RemoteExecution::Ssh.validate!
|
46
49
|
|
47
50
|
Proxy::Dynflow::TaskLauncherRegistry.register('ssh', Proxy::Dynflow::TaskLauncher::Batch)
|
51
|
+
if settings.mode == :'pull-mqtt'
|
52
|
+
require 'smart_proxy_remote_execution_ssh/mqtt'
|
53
|
+
# Force initialization
|
54
|
+
Proxy::RemoteExecution::Ssh::MQTT::Dispatcher.instance
|
55
|
+
end
|
48
56
|
end
|
49
57
|
|
50
58
|
def self.simulate?
|
@@ -133,7 +133,6 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
133
133
|
def cleanup
|
134
134
|
if @cleanup_working_dirs
|
135
135
|
ensure_remote_command("rm -rf #{remote_command_dir}",
|
136
|
-
publish: true,
|
137
136
|
error: "Unable to remove working directory #{remote_command_dir} on remote system, exit code: %{exit_code}")
|
138
137
|
end
|
139
138
|
end
|
@@ -32,3 +32,7 @@
|
|
32
32
|
# unset, SSL gets used if smart-proxy's foreman_ssl_cert, foreman_ssl_key and
|
33
33
|
# foreman_ssl_ca settings are set available.
|
34
34
|
# :mqtt_tls:
|
35
|
+
|
36
|
+
# The notification is sent over mqtt every $mqtt_resend_interval seconds, until
|
37
|
+
# the job is picked up by the host or cancelled
|
38
|
+
# :mqtt_resend_interval: 900
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: smart_proxy_remote_execution_ssh
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.10.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Ivan Nečas
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2022-12-12 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: bundler
|
@@ -175,6 +175,8 @@ files:
|
|
175
175
|
- lib/smart_proxy_remote_execution_ssh/http_config.ru
|
176
176
|
- lib/smart_proxy_remote_execution_ssh/job_storage.rb
|
177
177
|
- lib/smart_proxy_remote_execution_ssh/log_filter.rb
|
178
|
+
- lib/smart_proxy_remote_execution_ssh/mqtt.rb
|
179
|
+
- lib/smart_proxy_remote_execution_ssh/mqtt/dispatcher.rb
|
178
180
|
- lib/smart_proxy_remote_execution_ssh/multiplexed_ssh_connection.rb
|
179
181
|
- lib/smart_proxy_remote_execution_ssh/net_ssh_compat.rb
|
180
182
|
- lib/smart_proxy_remote_execution_ssh/plugin.rb
|
@@ -205,7 +207,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
205
207
|
- !ruby/object:Gem::Version
|
206
208
|
version: '0'
|
207
209
|
requirements: []
|
208
|
-
rubygems_version: 3.
|
210
|
+
rubygems_version: 3.3.20
|
209
211
|
signing_key:
|
210
212
|
specification_version: 4
|
211
213
|
summary: Ssh remote execution provider for Foreman Smart-Proxy
|