smart_proxy_remote_execution_ssh 0.8.0 → 0.10.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1a599d3732d4f6b064a850ae8baced28354980096785a340828533f953f844c4
4
- data.tar.gz: 160b80ab983f0eb7b987987191b48c19db42d3b76079b0ba6639ec831dccc6f3
3
+ metadata.gz: '048f8694430967060fae6bd7790fe56930b5c2bac37f3bc26e7b13db69081da6'
4
+ data.tar.gz: 5a3b1e344a47aa4c97dcce97f43dc9e424d398f79c67a6f1e066d670758c99f0
5
5
  SHA512:
6
- metadata.gz: 349961aa474809225d9781bda09687eb23f00db51cca9f758a57e075fa3d19fc07b729569b064887f91bb7f07190152d6ec9cb01e8ccf48df0841e9c3f85d10e
7
- data.tar.gz: 8ad40859c92f7d1cd089adf0f37bb0aaa8add745cd461c2e1ed5b484d3ae6b856ac0ee53e61c62e89d564a4ba676b22bf5f01618da970986cd60e42591ff6346
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[:job_uuid] = job_storage.store_job(host_name, execution_plan_id, run_step_id, input[:script].tr("\r", ''))
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
- mqtt_notify payload
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
- with_mqtt_client do |c|
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)}, '/usr/bin/sshpass', '-e', prompt].compact
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
- ['/usr/bin/ssh', reuse_ssh_options, cmd].flatten
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, '/usr/bin/ssh', establish_ssh_options, method.ssh_options, @host, 'true'].flatten
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 = ['/usr/bin/ssh-keygen', '-y', '-f', File.expand_path(@client_private_key_file)]
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
- :mode => :ssh
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
@@ -1,7 +1,7 @@
1
1
  module Proxy
2
2
  module RemoteExecution
3
3
  module Ssh
4
- VERSION = '0.8.0'
4
+ VERSION = '0.10.0'
5
5
  end
6
6
  end
7
7
  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.8.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: 1980-01-01 00:00:00.000000000 Z
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.2.26
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