foreman_remote_execution 1.1.1 → 1.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 4b2960d7df5f74d0bb9455ea4f024b1d31b9a68f
4
- data.tar.gz: 53d254b4f949852d53403d6ac15f6093211a3c79
3
+ metadata.gz: 8ca71ec8c1f3b330d327966298dea09c81a21378
4
+ data.tar.gz: a86edc45eaebca0fa3acfeecf38115fa6596d199
5
5
  SHA512:
6
- metadata.gz: 720aaba4a13126df8deea7d0566aa154da1b5bde179fecd51cd072f634f37f0c189f2d7942c665dba93b5078cb3872477735ac628d09ca035c394306acbe109d
7
- data.tar.gz: 4d57da51e9a0a8c651c778b5b0be52cc9c2159db07b532be6b2e4e1246f8d20b1b27f22217fdc028ea4dd180092963bcfec5da3e7b21f32a94629cf3e0599b5d
6
+ metadata.gz: db8e9e87cf16680859499d1f910f318b3ffba7e47bd180e30b5e43a25bebe04ef08a44b4225b519c73b312c4033d3b93dfababb80999ea04c667a0dbdd527d6f
7
+ data.tar.gz: 0d4b6974e4172fff1feee3a861cad6a14fb10788d1fb1a1dc8486328e94029681f3fbc9070394f706a434f6047dc2840f947f09422853d9faa551404698bd3ed
@@ -1,15 +1,16 @@
1
1
  module Actions
2
2
  module RemoteExecution
3
3
  class RunHostJob < Actions::EntryAction
4
+ include ::Actions::Helpers::WithContinuousOutput
5
+ include ::Actions::Helpers::WithDelegatedAction
4
6
 
5
7
  middleware.do_not_use Dynflow::Middleware::Common::Transaction
6
- include Actions::RemoteExecution::Helpers::LiveOutput
7
8
 
8
9
  def resource_locks
9
10
  :link
10
11
  end
11
12
 
12
- def plan(job_invocation, host, template_invocation, proxy, options = {})
13
+ def plan(job_invocation, host, template_invocation, proxy_selector = ::RemoteExecutionProxySelector.new, options = {})
13
14
  action_subject(host, :job_category => job_invocation.job_category, :description => job_invocation.description)
14
15
 
15
16
  template_invocation.host_id = host.id
@@ -24,18 +25,21 @@ module Actions
24
25
 
25
26
  raise _('Could not use any template used in the job invocation') if template_invocation.blank?
26
27
 
27
- if proxy.blank?
28
- offline_proxies = options.fetch(:offline_proxies, [])
28
+ provider = template_invocation.template.provider_type.to_s
29
+ proxy = proxy_selector.determine_proxy(host, provider)
30
+ if proxy == :not_available
31
+ offline_proxies = proxy_selector.offline
29
32
  settings = { :count => offline_proxies.count, :proxy_names => offline_proxies.map(&:name).join(', ') }
30
33
  raise n_('The only applicable proxy %{proxy_names} is down',
31
34
  'All %{count} applicable proxies are down. Tried %{proxy_names}',
32
- offline_proxies.count) % settings unless offline_proxies.empty?
33
-
34
- settings = { :global_proxy => 'remote_execution_global_proxy',
35
- :fallback_proxy => 'remote_execution_fallback_proxy' }
36
-
37
- raise _('Could not use any proxy. Consider configuring %{global_proxy} ' +
38
- 'or %{fallback_proxy} in settings') % settings
35
+ offline_proxies.count) % settings
36
+ elsif proxy == :not_defined && !Setting['remote_execution_without_proxy']
37
+ settings = { :global_proxy => 'remote_execution_global_proxy',
38
+ :fallback_proxy => 'remote_execution_fallback_proxy',
39
+ :no_proxy => 'remote_execution_no_proxy' }
40
+
41
+ raise _('Could not use any proxy. Consider configuring %{global_proxy}, ' +
42
+ '%{fallback_proxy} or %{no_proxy} in settings') % settings
39
43
  end
40
44
 
41
45
  renderer = InputTemplateRenderer.new(template_invocation.template, host, template_invocation)
@@ -43,28 +47,24 @@ module Actions
43
47
  raise _('Failed rendering template: %s') % renderer.error_message unless script
44
48
 
45
49
  provider = template_invocation.template.provider
46
- plan_action(RunProxyCommand, proxy, hostname, script, provider.proxy_command_options(template_invocation, host))
50
+ action_options = provider.proxy_command_options(template_invocation, host).merge(:hostname => hostname, :script => script)
51
+ plan_delegated_action(proxy, ForemanRemoteExecutionCore::Actions::RunScript, action_options)
47
52
  plan_self
48
53
  end
49
54
 
50
55
  def finalize(*args)
51
- host = Host.find(input[:host][:id])
52
- host.refresh_statuses
53
- rescue => e
54
- ::Foreman::Logging.exception "Could not update execution status for #{input[:host][:name]}", e
56
+ check_exit_status
55
57
  end
56
58
 
57
- def humanized_output
58
- live_output.map { |line| line['output'].chomp }.join("\n")
59
+ def check_exit_status
60
+ if delegated_output[:exit_status].to_s != '0'
61
+ error! _('Playbook execution failed')
62
+ end
59
63
  end
60
64
 
61
65
  def live_output
62
- proxy_command_action = planned_actions(RunProxyCommand).first
63
- if proxy_command_action
64
- proxy_command_action.live_output
65
- else
66
- execution_plan.errors.map { |e| exception_to_output(_('Failed to initialize command'), e) }
67
- end
66
+ continuous_output.sort!
67
+ continuous_output.raw_outputs
68
68
  end
69
69
 
70
70
  def humanized_input
@@ -76,6 +76,43 @@ module Actions
76
76
  N_('Remote action:')
77
77
  end
78
78
 
79
+ def finalize
80
+ if exit_status.to_s != '0'
81
+ error! _('Playbook execution failed')
82
+ end
83
+ end
84
+
85
+ def rescue_strategy
86
+ ::Dynflow::Action::Rescue::Fail
87
+ end
88
+
89
+ def humanized_output
90
+ continuous_output.humanize
91
+ end
92
+
93
+ def continuous_output_providers
94
+ super << self
95
+ end
96
+
97
+ def fill_continuous_output(continuous_output)
98
+ fill_planning_errors_to_continuous_output(continuous_output) unless exit_status
99
+ delegated_output.fetch('result', []).each do |raw_output|
100
+ continuous_output.add_raw_output(raw_output)
101
+ end
102
+ final_timestamp = (continuous_output.last_timestamp || task.ended_at).to_f + 1
103
+ if exit_status
104
+ continuous_output.add_output(_('Exit status: %s') % exit_status, 'stdout', final_timestamp)
105
+ elsif run_step && run_step.error
106
+ continuous_output.add_ouput(_('Job finished with error') + ": #{run_step.error.exception_class} - #{run_step.error.message}", 'debug', final_timestamp)
107
+ end
108
+ rescue => e
109
+ continuous_output.add_exception(_('Error loading data from proxy'), e)
110
+ end
111
+
112
+ def exit_status
113
+ delegated_output[:exit_status]
114
+ end
115
+
79
116
  def find_ip_or_hostname(host)
80
117
  %w(execution primary provision).each do |flag|
81
118
  interface = host.send(flag + '_interface')
@@ -91,6 +128,19 @@ module Actions
91
128
 
92
129
  private
93
130
 
131
+ def delegated_output
132
+ if input[:delegated_action_id]
133
+ super
134
+ elsif phase?(Present)
135
+ # for compatibility with old actions
136
+ if old_action = all_planned_actions.first
137
+ old_action.output.fetch('proxy_output', {})
138
+ else
139
+ {}
140
+ end
141
+ end
142
+ end
143
+
94
144
  def verify_permissions(host, template_invocation)
95
145
  raise _('User can not execute job on host %s') % host.name unless User.current.can?(:view_hosts, host)
96
146
  raise _('User can not execute this job template') unless User.current.can?(:view_job_templates, template_invocation.template)
@@ -23,16 +23,14 @@ module Actions
23
23
 
24
24
  def create_sub_plans
25
25
  job_invocation = JobInvocation.find(input[:job_invocation_id])
26
- load_balancer = ProxyLoadBalancer.new
26
+ proxy_selector = RemoteExecutionProxySelector.new
27
27
 
28
28
  # composer creates just "pattern" for template_invocations because target is evaluated
29
29
  # during actual run (here) so we build template invocations from these patterns
30
30
  job_invocation.targeting.hosts.map do |host|
31
31
  template_invocation = job_invocation.pattern_template_invocation_for_host(host).deep_clone
32
32
  template_invocation.host_id = host.id
33
- proxy = determine_proxy(template_invocation, host, load_balancer)
34
- trigger(RunHostJob, job_invocation, host, template_invocation, proxy,
35
- :offline_proxies => load_balancer.offline)
33
+ trigger(RunHostJob, job_invocation, host, template_invocation, proxy_selector)
36
34
  end
37
35
  end
38
36
 
@@ -56,22 +54,6 @@ module Actions
56
54
  def humanized_name
57
55
  '%s:' % _(super)
58
56
  end
59
-
60
- private
61
-
62
- def determine_proxy(template_invocation, host, load_balancer)
63
- provider = template_invocation.template.provider_type.to_s
64
- host_proxies = host.remote_execution_proxies(provider)
65
- strategies = [:subnet, :fallback, :global]
66
- proxy = nil
67
-
68
- strategies.each do |strategy|
69
- proxy = load_balancer.next(host_proxies[strategy]) if host_proxies[strategy].present?
70
- break if proxy
71
- end
72
-
73
- proxy
74
- end
75
57
  end
76
58
  end
77
59
  end
@@ -10,7 +10,7 @@ class HostStatus::ExecutionStatus < HostStatus::Status
10
10
  # mapping to string representation
11
11
  STATUS_NAMES = { OK => 'succeeded', ERROR => 'failed', QUEUED => 'queued', RUNNING => 'running' }
12
12
 
13
- def relevant?
13
+ def relevant?(*args)
14
14
  execution_tasks.present?
15
15
  end
16
16
 
@@ -14,6 +14,10 @@ class Setting::RemoteExecution < Setting
14
14
  "If locations or organizations are enabled, the search will be limited to the host's " +
15
15
  'organization or location.'),
16
16
  true),
17
+ self.set('remote_execution_without_proxy',
18
+ N_('When enabled, the remote execution will try to run the commands directly, when no
19
+ proxy with remote execution feature is configured for the host.'),
20
+ false),
17
21
  self.set('remote_execution_ssh_user',
18
22
  N_('Default user to use for SSH. You may override per host by setting a parameter called remote_execution_ssh_user.'),
19
23
  'root'),
@@ -0,0 +1,5 @@
1
+ class RemoteExecutionProxySelector < ::ForemanTasks::ProxySelector
2
+ def available_proxies(host, provider)
3
+ host.remote_execution_proxies(provider)
4
+ end
5
+ end
@@ -20,7 +20,8 @@ Gem::Specification.new do |s|
20
20
 
21
21
  s.add_dependency 'deface'
22
22
  s.add_dependency 'dynflow', '~> 0.8.10'
23
- s.add_dependency 'foreman-tasks', '0.8.0'
23
+ s.add_dependency 'foreman_remote_execution_core'
24
+ s.add_dependency 'foreman-tasks', '~> 0.8.1'
24
25
 
25
26
  s.add_development_dependency 'rubocop'
26
27
  s.add_development_dependency 'rdoc'
@@ -0,0 +1,23 @@
1
+ # -*- coding: utf-8 -*-
2
+ require File.expand_path('../lib/foreman_remote_execution_core/version', __FILE__)
3
+ require 'date'
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = 'foreman_remote_execution_core'
7
+ s.version = ForemanRemoteExecutionCore::VERSION
8
+ s.authors = ['Ivan Nečas']
9
+ s.email = ['inecas@redhat.com']
10
+ s.homepage = 'https://github.com/theforeman/foreman_remote_execution'
11
+ s.summary = 'Foreman remote execution - core bits'
12
+ s.description = <<DESC
13
+ Ssh remote execution provider code sharable between Foreman and Foreman-Proxy
14
+ DESC
15
+ s.licenses = ['GPL-3']
16
+
17
+ s.files = Dir['lib/foreman_remote_execution_core/**/*'] +
18
+ ['lib/foreman_remote_execution_core.rb']
19
+
20
+ s.add_runtime_dependency('foreman-tasks-core', '~> 0.1.0')
21
+ s.add_runtime_dependency('net-ssh')
22
+ s.add_runtime_dependency('net-scp')
23
+ end
@@ -1,3 +1,5 @@
1
+ require 'foreman_remote_execution_core'
2
+
1
3
  module ForemanRemoteExecution
2
4
  class Engine < ::Rails::Engine
3
5
  engine_name 'foreman_remote_execution'
@@ -1,3 +1,3 @@
1
1
  module ForemanRemoteExecution
2
- VERSION = '1.1.1'
2
+ VERSION = '1.2.0'
3
3
  end
@@ -0,0 +1,18 @@
1
+ require 'foreman_tasks_core'
2
+
3
+ module ForemanRemoteExecutionCore
4
+ extend ForemanTasksCore::SettingsLoader
5
+ register_settings([:remote_execution_ssh, :smart_proxy_remote_execution_ssh_core],
6
+ :ssh_identity_key_file => '~/.ssh/id_rsa_foreman_proxy',
7
+ :ssh_user => 'root',
8
+ :remote_working_dir => '/var/tmp',
9
+ :local_working_dir => '/var/tmp')
10
+
11
+ if ForemanTasksCore.dynflow_present?
12
+ require 'foreman_tasks_core/runner'
13
+ require 'foreman_remote_execution_core/script_runner'
14
+ require 'foreman_remote_execution_core/actions'
15
+ end
16
+
17
+ require 'foreman_remote_execution_core/version'
18
+ end
@@ -0,0 +1,11 @@
1
+ require 'foreman_tasks_core/shareable_action'
2
+
3
+ module ForemanRemoteExecutionCore
4
+ module Actions
5
+ class RunScript < ForemanTasksCore::Runner::Action
6
+ def initiate_runner
7
+ ScriptRunner.new(input)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,266 @@
1
+ require 'net/ssh'
2
+ require 'net/scp'
3
+
4
+ module ForemanRemoteExecutionCore
5
+ class ScriptRunner < ForemanTasksCore::Runner::Base
6
+ EXPECTED_POWER_ACTION_MESSAGES = ["restart host", "shutdown host"]
7
+
8
+ def initialize(options)
9
+ super()
10
+ @host = options.fetch(:hostname)
11
+ @script = options.fetch(:script)
12
+ @ssh_user = options.fetch(:ssh_user, 'root')
13
+ @ssh_port = options.fetch(:ssh_port, 22)
14
+ @effective_user = options.fetch(:effective_user, nil)
15
+ @effective_user_method = options.fetch(:effective_user_method, 'sudo')
16
+ @host_public_key = options.fetch(:host_public_key, nil)
17
+ @verify_host = options.fetch(:verify_host, nil)
18
+
19
+ @client_private_key_file = settings.fetch(:ssh_identity_key_file)
20
+ @local_working_dir = options.fetch(:local_working_dir, settings.fetch(:local_working_dir))
21
+ @remote_working_dir = options.fetch(:remote_working_dir, settings.fetch(:remote_working_dir))
22
+ end
23
+
24
+ def start
25
+ remote_script = cp_script_to_remote
26
+ output_path = File.join(File.dirname(remote_script), 'output')
27
+
28
+ # pipe the output to tee while capturing the exit code
29
+ script = <<-SCRIPT
30
+ exec 4>&1
31
+ exit_code=`((#{su_prefix}#{remote_script}; echo $?>&3 ) | /usr/bin/tee #{output_path} ) 3>&1 >&4`
32
+ exec 4>&-
33
+ exit $exit_code
34
+ SCRIPT
35
+ logger.debug("executing script:\n#{script.lines.map { |line| " | #{line}" }.join}")
36
+ run_async(script)
37
+ rescue => e
38
+ logger.error("error while initalizing command #{e.class} #{e.message}:\n #{e.backtrace.join("\n")}")
39
+ publish_exception("Error initializing command", e)
40
+ end
41
+
42
+ def refresh
43
+ return if @session.nil?
44
+ with_retries do
45
+ with_disconnect_handling do
46
+ @session.process(0)
47
+ end
48
+ end
49
+ ensure
50
+ check_expecting_disconnect
51
+ end
52
+
53
+ def kill
54
+ if @session
55
+ run_sync("pkill -f #{remote_command_file('script')}")
56
+ else
57
+ logger.debug("connection closed")
58
+ end
59
+ rescue => e
60
+ publish_exception("Unexpected error", e, false)
61
+ end
62
+
63
+ def with_retries
64
+ tries = 0
65
+ begin
66
+ yield
67
+ rescue => e
68
+ logger.error("Unexpected error: #{e.class} #{e.message}\n #{e.backtrace.join("\n")}")
69
+ tries += 1
70
+ if tries <= MAX_PROCESS_RETRIES
71
+ logger.error('Retrying')
72
+ retry
73
+ else
74
+ publish_exception("Unexpected error", e)
75
+ end
76
+ end
77
+ end
78
+
79
+ def with_disconnect_handling
80
+ yield
81
+ rescue Net::SSH::Disconnect => e
82
+ @session.shutdown!
83
+ check_expecting_disconnect
84
+ if @expecting_disconnect
85
+ publish_exit_status(0)
86
+ else
87
+ publish_exception("Unexpected disconnect", e)
88
+ end
89
+ end
90
+
91
+ def close
92
+ @session.close if @session && !@session.closed?
93
+ end
94
+
95
+ private
96
+
97
+ def session
98
+ @session ||= begin
99
+ @logger.debug("opening session to #{@ssh_user}@#{@host}")
100
+ Net::SSH.start(@host, @ssh_user, ssh_options)
101
+ end
102
+ end
103
+
104
+ def ssh_options
105
+ ssh_options = {}
106
+ ssh_options[:port] = @ssh_port if @ssh_port
107
+ ssh_options[:keys] = [@client_private_key_file] if @client_private_key_file
108
+ ssh_options[:user_known_hosts_file] = @known_hosts_file if @known_hosts_file
109
+ ssh_options[:keys_only] = true
110
+ # if the host public key is contained in the known_hosts_file,
111
+ # verify it, otherwise, if missing, import it and continue
112
+ ssh_options[:paranoid] = true
113
+ ssh_options[:auth_methods] = ["publickey"]
114
+ ssh_options[:user_known_hosts_file] = prepare_known_hosts if @host_public_key
115
+ return ssh_options
116
+ end
117
+
118
+ def settings
119
+ ForemanRemoteExecutionCore.settings
120
+ end
121
+
122
+ # Initiates run of the remote command and yields the data when
123
+ # available. The yielding doesn't happen automatically, but as
124
+ # part of calling the `refresh` method.
125
+ def run_async(command)
126
+ raise "Async command already in progress" if @started
127
+ @started = false
128
+ session.open_channel do |channel|
129
+ channel.request_pty
130
+ channel.on_data { |ch, data| publish_data(data, 'stdout') }
131
+ channel.on_extended_data { |ch, type, data| publish_data(data, 'stderr') }
132
+ # standard exit of the command
133
+ channel.on_request("exit-status") { |ch, data| publish_exit_status(data.read_long) }
134
+ # on signal: sending the signal value (such as 'TERM')
135
+ channel.on_request("exit-signal") do |ch, data|
136
+ publish_exit_status(data.read_string)
137
+ ch.close
138
+ # wait for the channel to finish so that we know at the end
139
+ # that the session is inactive
140
+ ch.wait
141
+ end
142
+ channel.exec(command) do |ch, success|
143
+ @started = true
144
+ raise("Error initializing command") unless success
145
+ end
146
+ end
147
+ session.process(0) until @started
148
+ return true
149
+ end
150
+
151
+ def run_sync(command)
152
+ output = ""
153
+ exit_status = nil
154
+ channel = session.open_channel do |ch|
155
+ ch.on_data { |data| output.concat(data) }
156
+ ch.on_extended_data { |_, _, data| output.concat(data) }
157
+ ch.on_request("exit-status") { |_, data| exit_status = data.read_long }
158
+ # on signal: sending the signal value (such as 'TERM')
159
+ ch.on_request("exit-signal") do |_, data|
160
+ exit_status = data.read_string
161
+ ch.close
162
+ ch.wait
163
+ end
164
+ ch.exec command do |_, success|
165
+ raise "could not execute command" unless success
166
+ end
167
+ end
168
+ channel.wait
169
+ return exit_status, output
170
+ end
171
+
172
+ def su_prefix
173
+ return if @effective_user.nil? || @effective_user == @ssh_user
174
+ case @effective_user_method
175
+ when 'sudo'
176
+ "sudo -n -u #{@effective_user} "
177
+ when 'su'
178
+ "su - #{@effective_user} -c "
179
+ else
180
+ raise "effective_user_method ''#{@effective_user_method}'' not supported"
181
+ end
182
+ end
183
+
184
+ def prepare_known_hosts
185
+ path = local_command_file('known_hosts')
186
+ if @host_public_key
187
+ write_command_file_locally('known_hosts', "#{@host} #{@host_public_key}")
188
+ end
189
+ return path
190
+ end
191
+
192
+ def local_command_dir
193
+ File.join(@local_working_dir, 'foreman-proxy', "foreman-ssh-cmd-#{@id}")
194
+ end
195
+
196
+ def local_command_file(filename)
197
+ File.join(local_command_dir, filename)
198
+ end
199
+
200
+ def remote_command_dir
201
+ File.join(@remote_working_dir, "foreman-ssh-cmd-#{id}")
202
+ end
203
+
204
+ def remote_command_file(filename)
205
+ File.join(remote_command_dir, filename)
206
+ end
207
+
208
+ def ensure_local_directory(path)
209
+ if File.exist?(path)
210
+ raise "#{path} expected to be a directory" unless File.directory?(path)
211
+ else
212
+ FileUtils.mkdir_p(path)
213
+ end
214
+ return path
215
+ end
216
+
217
+ def cp_script_to_remote
218
+ local_script_file = write_command_file_locally('script', sanitize_script(@script))
219
+ File.chmod(0555, local_script_file)
220
+ remote_script_file = remote_command_file('script')
221
+ upload_file(local_script_file, remote_script_file)
222
+ return remote_script_file
223
+ end
224
+
225
+ def upload_file(local_path, remote_path)
226
+ ensure_remote_directory(File.dirname(remote_path))
227
+ scp = Net::SCP.new(session)
228
+ upload_channel = scp.upload(local_path, remote_path)
229
+ upload_channel.wait
230
+ ensure
231
+ if upload_channel
232
+ upload_channel.close
233
+ upload_channel.wait
234
+ end
235
+ end
236
+
237
+ def ensure_remote_directory(path)
238
+ exit_code, output = run_sync("mkdir -p #{path}")
239
+ if exit_code != 0
240
+ raise "Unable to create directory on remote system #{path}: exit code: #{exit_code}\n #{output}"
241
+ end
242
+ end
243
+
244
+ def sanitize_script(script)
245
+ script.tr("\r", '')
246
+ end
247
+
248
+ def write_command_file_locally(filename, content)
249
+ path = local_command_file(filename)
250
+ ensure_local_directory(File.dirname(path))
251
+ File.write(path, content)
252
+ return path
253
+ end
254
+
255
+ # when a remote server disconnects, it's hard to tell if it was on purpose (when calling reboot)
256
+ # or it's an error. When it's expected, we expect the script to produce 'restart host' as
257
+ # its last command output
258
+ def check_expecting_disconnect
259
+ last_output = @continuous_output.raw_outputs.find { |d| d['output_type'] == 'stdout' }
260
+ return unless last_output
261
+ if EXPECTED_POWER_ACTION_MESSAGES.any? { |message| last_output['output'] =~ /^#{message}/ }
262
+ @expecting_disconnect = true
263
+ end
264
+ end
265
+ end
266
+ end
@@ -0,0 +1,3 @@
1
+ module ForemanRemoteExecutionCore
2
+ VERSION = '1.0.0'
3
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foreman_remote_execution
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.1
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Foreman Remote Execution team
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2016-09-07 00:00:00.000000000 Z
11
+ date: 2016-09-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: deface
@@ -38,20 +38,34 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: 0.8.10
41
+ - !ruby/object:Gem::Dependency
42
+ name: foreman_remote_execution_core
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: foreman-tasks
43
57
  requirement: !ruby/object:Gem::Requirement
44
58
  requirements:
45
- - - '='
59
+ - - "~>"
46
60
  - !ruby/object:Gem::Version
47
- version: 0.8.0
61
+ version: 0.8.1
48
62
  type: :runtime
49
63
  prerelease: false
50
64
  version_requirements: !ruby/object:Gem::Requirement
51
65
  requirements:
52
- - - '='
66
+ - - "~>"
53
67
  - !ruby/object:Gem::Version
54
- version: 0.8.0
68
+ version: 0.8.1
55
69
  - !ruby/object:Gem::Dependency
56
70
  name: rubocop
57
71
  requirement: !ruby/object:Gem::Requirement
@@ -160,10 +174,8 @@ files:
160
174
  - app/helpers/concerns/foreman_remote_execution/job_templates_extensions.rb
161
175
  - app/helpers/remote_execution_helper.rb
162
176
  - app/lib/actions/middleware/bind_job_invocation.rb
163
- - app/lib/actions/remote_execution/helpers/live_output.rb
164
177
  - app/lib/actions/remote_execution/run_host_job.rb
165
178
  - app/lib/actions/remote_execution/run_hosts_job.rb
166
- - app/lib/actions/remote_execution/run_proxy_command.rb
167
179
  - app/lib/proxy_api/remote_execution_ssh.rb
168
180
  - app/mailers/.gitkeep
169
181
  - app/models/concerns/foreman_remote_execution/bookmark_extensions.rb
@@ -198,7 +210,7 @@ files:
198
210
  - app/models/template_invocation_input_value.rb
199
211
  - app/overrides/execution_interface.rb
200
212
  - app/overrides/subnet_proxies.rb
201
- - app/services/proxy_load_balancer.rb
213
+ - app/services/remote_execution_proxy_selector.rb
202
214
  - app/views/api/v2/foreign_input_sets/base.json.rabl
203
215
  - app/views/api/v2/foreign_input_sets/create.json.rabl
204
216
  - app/views/api/v2/foreign_input_sets/index.json.rabl
@@ -339,9 +351,14 @@ files:
339
351
  - doc/source/static/js/jquery.tocify.min.js
340
352
  - doc/source/static/js/scroll.js
341
353
  - foreman_remote_execution.gemspec
354
+ - foreman_remote_execution_core.gemspec
342
355
  - lib/foreman_remote_execution.rb
343
356
  - lib/foreman_remote_execution/engine.rb
344
357
  - lib/foreman_remote_execution/version.rb
358
+ - lib/foreman_remote_execution_core.rb
359
+ - lib/foreman_remote_execution_core/actions.rb
360
+ - lib/foreman_remote_execution_core/script_runner.rb
361
+ - lib/foreman_remote_execution_core/version.rb
345
362
  - lib/tasks/foreman_remote_execution_tasks.rake
346
363
  - locale/Makefile
347
364
  - locale/action_names.rb
@@ -378,7 +395,6 @@ files:
378
395
  - test/test_plugin_helper.rb
379
396
  - test/unit/actions/run_host_job_test.rb
380
397
  - test/unit/actions/run_hosts_job_test.rb
381
- - test/unit/actions/run_proxy_command_test.rb
382
398
  - test/unit/concerns/exportable_test.rb
383
399
  - test/unit/concerns/host_extensions_test.rb
384
400
  - test/unit/concerns/nic_extensions_test.rb
@@ -388,7 +404,6 @@ files:
388
404
  - test/unit/job_invocation_test.rb
389
405
  - test/unit/job_template_effective_user_test.rb
390
406
  - test/unit/job_template_test.rb
391
- - test/unit/proxy_load_balancer_test.rb
392
407
  - test/unit/remote_execution_feature_test.rb
393
408
  - test/unit/remote_execution_provider_test.rb
394
409
  - test/unit/targeting_test.rb
@@ -428,7 +443,6 @@ test_files:
428
443
  - test/test_plugin_helper.rb
429
444
  - test/unit/actions/run_host_job_test.rb
430
445
  - test/unit/actions/run_hosts_job_test.rb
431
- - test/unit/actions/run_proxy_command_test.rb
432
446
  - test/unit/concerns/exportable_test.rb
433
447
  - test/unit/concerns/host_extensions_test.rb
434
448
  - test/unit/concerns/nic_extensions_test.rb
@@ -438,7 +452,6 @@ test_files:
438
452
  - test/unit/job_invocation_test.rb
439
453
  - test/unit/job_template_effective_user_test.rb
440
454
  - test/unit/job_template_test.rb
441
- - test/unit/proxy_load_balancer_test.rb
442
455
  - test/unit/remote_execution_feature_test.rb
443
456
  - test/unit/remote_execution_provider_test.rb
444
457
  - test/unit/targeting_test.rb
@@ -1,17 +0,0 @@
1
- module Actions
2
- module RemoteExecution
3
- module Helpers
4
- module LiveOutput
5
- def exception_to_output(context, exception, timestamp = Time.now.getlocal)
6
- format_output(context + ": #{exception.class} - #{exception.message}", 'debug', timestamp)
7
- end
8
-
9
- def format_output(message, type = 'debug', timestamp = Time.now.getlocal)
10
- { 'output_type' => type,
11
- 'output' => message,
12
- 'timestamp' => timestamp.to_f }
13
- end
14
- end
15
- end
16
- end
17
- end
@@ -1,96 +0,0 @@
1
- module Actions
2
- module RemoteExecution
3
- class RunProxyCommand < Actions::ProxyAction
4
-
5
- include ::Dynflow::Action::Cancellable
6
- include Actions::RemoteExecution::Helpers::LiveOutput
7
-
8
- def plan(proxy, hostname, script, options = {})
9
- options = { :effective_user => nil }.merge(options)
10
- super(proxy, options.merge(:hostname => hostname, :script => script))
11
- end
12
-
13
- def proxy_action_name
14
- 'Proxy::RemoteExecution::Ssh::CommandAction'
15
- end
16
-
17
- def on_data(data)
18
- if data[:result] == 'initialization_error'
19
- handle_connection_exception(data[:metadata][:exception_class]
20
- .constantize
21
- .new(data[:metadata][:exception_message]))
22
- else
23
- super(data)
24
- error! _('Script execution failed') if failed_run?
25
- end
26
- end
27
-
28
-
29
- def rescue_strategy
30
- ::Dynflow::Action::Rescue::Skip
31
- end
32
-
33
- def failed_run?
34
- output[:result] == 'initialization_error' ||
35
- (exit_status && proxy_output[:exit_status] != 0)
36
- end
37
-
38
- def exit_status
39
- proxy_output && proxy_output[:exit_status]
40
- end
41
-
42
- def live_output
43
- records = connection_messages
44
- if !task.pending?
45
- records.concat(finalized_output)
46
- else
47
- records.concat(current_proxy_output)
48
- end
49
- records.sort_by { |record| record['timestamp'].to_f }
50
- end
51
-
52
- private
53
-
54
- def connection_messages
55
- metadata.fetch(:failed_proxy_tasks, []).map do |failure_data|
56
- format_output(_('Initialization error: %s') % "#{failure_data[:exception_class]} - #{failure_data[:exception_message]}", 'debug', failure_data[:timestamp])
57
- end
58
- end
59
-
60
- def current_proxy_output
61
- return [] unless output[:proxy_task_id]
62
- proxy_data = proxy.status_of_task(output[:proxy_task_id])['actions'].detect { |action| action['class'] == proxy_action_name }
63
- proxy_data.fetch('output', {}).fetch('result', [])
64
- rescue => e
65
- ::Foreman::Logging.exception("Failed to load data for task #{task.id} from proxy #{input[:proxy_url]}", e)
66
- [exception_to_output(_('Error loading data from proxy'), e)]
67
- end
68
-
69
- def finalized_output
70
- records = []
71
-
72
- if proxy_result.present?
73
- records.concat(proxy_result)
74
- else
75
- records << format_output(_('No output'))
76
- end
77
-
78
- if exit_status
79
- records << format_output(_('Exit status: %s') % exit_status, 'stdout', final_timestamp(records))
80
- elsif run_step && run_step.error
81
- records << format_output(_('Job finished with error') + ": #{run_step.error.exception_class} - #{run_step.error.message}", 'debug', final_timestamp(records))
82
- end
83
- return records
84
- end
85
-
86
- def final_timestamp(records)
87
- return task.ended_at if records.blank?
88
- records.last.fetch('timestamp', task.ended_at).to_f + 1
89
- end
90
-
91
- def proxy_result
92
- self.output.fetch(:proxy_output, {}).fetch(:result, []) || []
93
- end
94
- end
95
- end
96
- end
@@ -1,33 +0,0 @@
1
- class ProxyLoadBalancer
2
-
3
- attr_reader :offline
4
-
5
- def initialize
6
- @tasks = {}
7
- @offline = []
8
- end
9
-
10
- # Get the least loaded proxy from the given list of proxies
11
- def next(proxies)
12
- exclude = @tasks.keys + @offline
13
- @tasks.merge!(get_counts(proxies - exclude))
14
- next_proxy = @tasks.select { |proxy, _| proxies.include?(proxy) }.min_by { |_, job_count| job_count }.try(:first)
15
- @tasks[next_proxy] += 1 if next_proxy.present?
16
- next_proxy
17
- end
18
-
19
- private
20
-
21
- def get_counts(proxies)
22
- proxies.inject({}) do |result, proxy|
23
- begin
24
- proxy_api = ProxyAPI::ForemanDynflow::DynflowProxy.new(:url => proxy.url)
25
- result[proxy] = proxy_api.tasks_count('running')
26
- rescue => e
27
- @offline << proxy
28
- Foreman::Logging.exception "Could not fetch task counts from #{proxy}, skipped.", e
29
- end
30
- result
31
- end
32
- end
33
- end
@@ -1,134 +0,0 @@
1
- require 'test_plugin_helper'
2
-
3
- module ForemanRemoteExecution
4
- class RunProxyCommandTest < ActiveSupport::TestCase
5
- include Dynflow::Testing
6
-
7
- let(:host) { FactoryGirl.build(:host, :with_execution) }
8
- let(:proxy) { host.remote_execution_proxies('SSH')[:subnet].first }
9
- let(:hostname) { 'myhost.example.com' }
10
- let(:script) { 'ping -c 5 redhat.com' }
11
- let(:connection_options) { { 'retry_interval' => 15, 'retry_count' => 4, 'timeout' => 60 } }
12
- let(:action) do
13
- create_and_plan_action(Actions::RemoteExecution::RunProxyCommand, proxy, host.name, script)
14
- end
15
- let(:timestamp) { 1_443_194_805.9192207 }
16
-
17
- it 'plans for running the command action on server' do
18
- assert_run_phase action, { :hostname => host.name,
19
- :script => script,
20
- :proxy_url => proxy.url,
21
- :effective_user => nil,
22
- :connection_options => connection_options }
23
- end
24
-
25
- it 'sends to command to ssh provider' do
26
- action.proxy_action_name.must_equal 'Proxy::RemoteExecution::Ssh::CommandAction'
27
- end
28
-
29
- it "doesn't block on failure" do
30
- action.rescue_strategy.must_equal ::Dynflow::Action::Rescue::Skip
31
- end
32
-
33
- describe '#live_output' do
34
- let(:task) { ForemanTasks::Task.new }
35
-
36
- let(:action) do
37
- planned_action = create_and_plan_action(Actions::RemoteExecution::RunProxyCommand, proxy, hostname, script)
38
- create_action_presentation(Actions::RemoteExecution::RunProxyCommand).tap do |action|
39
- action.stubs(:task).returns(task)
40
- action.stubs(:proxy).returns(ProxyAPI::ForemanDynflow::DynflowProxy.new(:url => proxy.url))
41
- action.instance_variable_set('@input', planned_action.input)
42
- action.output.merge!(:proxy_task_id => '123')
43
- end
44
- end
45
-
46
- let(:live_output) do
47
- action.live_output
48
- end
49
-
50
- describe 'when the task is finished' do
51
- before do
52
- task.state = 'stopped'
53
- task.ended_at = Time.at(timestamp + 1).utc
54
- end
55
-
56
- describe 'the task finished sucessfully' do
57
- before do
58
- action.output.merge!(:proxy_output => { :result => [{ 'output_type' => 'stdout', 'output' => 'Hello', 'timestamp' => timestamp}],
59
- :exit_status => 0 })
60
- end
61
-
62
- it "doesn't fetch data from proxy anymore" do
63
- action.proxy.expects(:status_of_task).never
64
- live_output.size.must_equal 2
65
- live_output[0]['output_type'].must_equal 'stdout'
66
- live_output[0]['output'].must_equal 'Hello'
67
- live_output[0]['timestamp'].must_be_kind_of Float
68
- live_output[1]['output_type'].must_equal 'stdout'
69
- live_output[1]['output'].must_equal 'Exit status: 0'
70
- live_output[1]['timestamp'].must_be_kind_of Float
71
- end
72
- end
73
-
74
- describe 'there was not output data from proxy' do
75
- before do
76
- action.output.merge!(:proxy_output => {})
77
- end
78
-
79
- it "doesn't fetch data from proxy anymore" do
80
- action.proxy.expects(:status_of_task).never
81
- live_output.size.must_equal 1
82
- live_output[0]['output_type'].must_equal 'debug'
83
- live_output[0]['output'].must_equal 'No output'
84
- live_output[0]['timestamp'].must_be_kind_of Float
85
- end
86
- end
87
- end
88
-
89
- describe 'when something went wrong while fetching the data' do
90
- before do
91
- action.proxy.expects(:status_of_task).raises('Something went wrong')
92
- end
93
-
94
- it 'reports the failure as part of the live output' do
95
- live_output.size.must_equal 1
96
- live_output.first['output_type'].must_equal 'debug'
97
- live_output.first['output'].must_equal 'Error loading data from proxy: RuntimeError - Something went wrong'
98
- live_output.first['timestamp'].must_be_kind_of Float
99
- end
100
- end
101
-
102
- describe 'when there was some connection error while running the command' do
103
- before do
104
- action.output.merge!(:proxy_task_id => nil,
105
- :metadata => { :failed_proxy_tasks => [action.send(:format_exception, RuntimeError.new('Connection error'))]})
106
- end
107
-
108
- it 'reports the failure as part of the live output' do
109
- live_output.size.must_equal 1
110
- live_output.first['output_type'].must_equal 'debug'
111
- live_output.first['output'].must_equal 'Initialization error: RuntimeError - Connection error'
112
- live_output.first['timestamp'].must_be_kind_of Float
113
- end
114
- end
115
-
116
- describe 'when proxy returns valid data' do
117
- before do
118
- action.proxy.expects(:status_of_task).returns('actions' =>
119
- [{ 'class' => 'Proxy::RemoteExecution::Ssh::CommandAction',
120
- 'output' => { 'result' => [ { 'output_type' => 'stdout',
121
- 'output' => 'Hello',
122
- 'timestamp' => timestamp }]}}])
123
- end
124
-
125
- it 'reports the failure as part of the live output' do
126
- live_output.size.must_equal 1
127
- live_output.first['output_type'].must_equal 'stdout'
128
- live_output.first['output'].must_equal 'Hello'
129
- live_output.first['timestamp'].must_be_kind_of Float
130
- end
131
- end
132
- end
133
- end
134
- end
@@ -1,27 +0,0 @@
1
- require 'test_plugin_helper'
2
-
3
- describe ProxyLoadBalancer do
4
- let(:load_balancer) { ProxyLoadBalancer.new }
5
-
6
- before do
7
- ProxyAPI::ForemanDynflow::DynflowProxy.any_instance.stubs(:tasks_count).returns(0)
8
- end
9
-
10
- it 'load balances' do
11
- count = 3
12
- ProxyAPI::ForemanDynflow::DynflowProxy.any_instance.expects(:tasks_count).raises.then.times(count - 1).returns(0)
13
- proxies = FactoryGirl.create_list(:smart_proxy, count, :ssh)
14
-
15
- available = proxies.reduce([]) do |found, _|
16
- found << load_balancer.next(proxies)
17
- end
18
-
19
- available.count.must_equal count
20
- available.uniq.count.must_equal count - 1
21
- load_balancer.offline.count.must_equal 1
22
- end
23
-
24
- it 'returns nil for if no proxy is available' do
25
- load_balancer.next([]).must_be_nil
26
- end
27
- end