smart_proxy_ansible 3.1.0 → 3.3.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
  SHA256:
3
- metadata.gz: 723f4dc74c428f9eb0d34bda1f492e8b5e49eb2103f16ccb36b279db8dfb3045
4
- data.tar.gz: 553c1a6918b5f345d7017fe9ce691bffed6903c7d1e5fdbcbe5905a863e70542
3
+ metadata.gz: 4a1934350dedb103c779cdf61196cff80a43751d260fd5f550d26289dc63f388
4
+ data.tar.gz: cee72032c8d95fde92021ef8c312e9c4f0408b9ac1d88b5a0ebf9cdd97bc906c
5
5
  SHA512:
6
- metadata.gz: 2bde47200bda54b10495128b81a19f3f6471f1c9142e034537df43e6d8b6badfcf7685a59923d5bc008263231a64d414ab5c6aecb4f0adf3342f3dfb300a35d1
7
- data.tar.gz: 6793a645db2e1e1ac75d1136cfa5e10285a2fb6b100d97b7a67613e7e4cac504f0154b398ed066f9ea17cb8f65589a8b76df60fece50764a9bb658c9181d145a
6
+ metadata.gz: a473838108c4b9cbcef9da311410a2fda5bc39047a2cd4c623533ef718918583de1c830105b92a52303bfc41e1eeb933f1d4a898380a0cf9b01c32d7fb07cb76
7
+ data.tar.gz: cf1aa8ca722850fd39d312cefcbf178e193fcf391ca8058638dd26db01c288f7350ea6c80e5fb004e87cdda664f5200f24b83b61a2dc3226d88006d0efff7cb3
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env bash
2
+ # An inventory script to load the inventory from JSON file.
3
+ #
4
+
5
+ if [ -z "$JSON_INVENTORY_FILE" ]; then
6
+ echo "JSON_INVENTORY_FILE not specified"
7
+ exit 1
8
+ fi
9
+
10
+ cat $JSON_INVENTORY_FILE
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'smart_proxy_dynflow/action/runner'
4
+
5
+ module Proxy::Ansible
6
+ module Actions
7
+ # Action that can be run both on Foreman or Foreman proxy side
8
+ # to execute the playbook run
9
+ class RunPlaybook < Proxy::Dynflow::Action::Runner
10
+ def initiate_runner
11
+ Proxy::Ansible::Runner::Playbook.new(
12
+ input[:inventory],
13
+ input[:playbook],
14
+ input[:options]
15
+ )
16
+ end
17
+ end
18
+ end
19
+ end
@@ -28,15 +28,23 @@ module Proxy
28
28
  {}.to_json
29
29
  end
30
30
 
31
+ get '/playbooks_names' do
32
+ PlaybooksReader.playbooks_names.to_json
33
+ end
34
+
35
+ get '/playbooks/:playbooks_names?' do
36
+ PlaybooksReader.playbooks(params[:playbooks_names]).to_json
37
+ end
38
+
31
39
  private
32
40
 
33
41
  def extract_variables(role_name)
34
42
  variables = {}
35
43
  role_name_parts = role_name.split('.')
36
44
  if role_name_parts.count == 3
37
- RolesReader.collections_paths.split(':').each do |path|
38
- variables[role_name] ||= VariablesExtractor
39
- .extract_variables("#{path}/ansible_collections/#{role_name_parts[0]}/#{role_name_parts[1]}/roles/#{role_name_parts[2]}")
45
+ ReaderHelper.collections_paths.split(':').each do |path|
46
+ variables[role_name] = VariablesExtractor
47
+ .extract_variables("#{path}/ansible_collections/#{role_name_parts[0]}/#{role_name_parts[1]}/roles/#{role_name_parts[2]}") if variables[role_name].nil? || variables[role_name].empty?
40
48
  end
41
49
  else
42
50
  RolesReader.roles_path.split(':').each do |path|
@@ -0,0 +1,20 @@
1
+ module Proxy::Ansible
2
+ class ConfigurationLoader
3
+ def load_classes
4
+ require 'smart_proxy_dynflow'
5
+ require 'smart_proxy_dynflow/continuous_output'
6
+ require 'smart_proxy_ansible/task_launcher/ansible_runner'
7
+ require 'smart_proxy_ansible/task_launcher/playbook'
8
+ require 'smart_proxy_ansible/actions'
9
+ require 'smart_proxy_ansible/remote_execution_core/ansible_runner'
10
+ require 'smart_proxy_ansible/runner/ansible_runner'
11
+ require 'smart_proxy_ansible/runner/command_creator'
12
+ require 'smart_proxy_ansible/runner/playbook'
13
+
14
+ Proxy::Dynflow::TaskLauncherRegistry.register('ansible-runner',
15
+ TaskLauncher::AnsibleRunner)
16
+ Proxy::Dynflow::TaskLauncherRegistry.register('ansible-playbook',
17
+ TaskLauncher::Playbook)
18
+ end
19
+ end
20
+ end
@@ -37,5 +37,8 @@ module Proxy
37
37
  class ReadConfigFileException < Proxy::Ansible::Exception; end
38
38
  class ReadRolesException < Proxy::Ansible::Exception; end
39
39
  class ReadVariablesException < Proxy::Ansible::Exception; end
40
+ class NotExistingWorkingDirException < Proxy::Ansible::Exception; end
41
+ class ReadPlaybooksNamesException < Proxy::Ansible::Exception; end
42
+ class ReadPlaybooksException < Proxy::Ansible::Exception; end
40
43
  end
41
44
  end
@@ -0,0 +1,37 @@
1
+ module Proxy
2
+ module Ansible
3
+ # Implements the logic needed to read the playbooks and associated information
4
+ class PlaybooksReader
5
+ class << self
6
+ def playbooks_names
7
+ ReaderHelper.collections_paths.split(':').flat_map { |path| get_playbooks_names(path) }
8
+ end
9
+
10
+ def playbooks(playbooks_to_import)
11
+ ReaderHelper.collections_paths.split(':').reduce([]) do |playbooks, path|
12
+ playbooks.concat(read_collection_playbooks(path, playbooks_to_import))
13
+ end
14
+ end
15
+
16
+ def get_playbooks_names(collections_path)
17
+ Dir.glob("#{collections_path}/ansible_collections/*/*/playbooks/*").map do |path|
18
+ ReaderHelper.playbook_or_role_full_name(path)
19
+ end
20
+ end
21
+
22
+ def read_collection_playbooks(collections_path, playbooks_to_import = nil)
23
+ Dir.glob("#{collections_path}/ansible_collections/*/*/playbooks/*").map do |path|
24
+ name = ReaderHelper.playbook_or_role_full_name(path)
25
+ {
26
+ name: name,
27
+ playbooks_content: File.readlines(path)
28
+ } if playbooks_to_import.nil? || playbooks_to_import.include?(name)
29
+ end.compact
30
+ rescue Errno::ENOENT, Errno::EACCES => e
31
+ message = "Could not read Ansible playbooks #{collections_path} - #{e.message}"
32
+ raise ReadPlaybooksException, message
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -5,17 +5,12 @@ module Proxy
5
5
  rackup_path File.expand_path('http_config.ru', __dir__)
6
6
  settings_file 'ansible.yml'
7
7
  plugin :ansible, Proxy::Ansible::VERSION
8
+ default_settings :ansible_dir => Dir.home
9
+ # :working_dir => nil
8
10
 
9
- after_activation do
10
- begin
11
- require 'smart_proxy_dynflow_core'
12
- require 'foreman_ansible_core'
13
- ForemanAnsibleCore.initialize_settings(Proxy::Ansible::Plugin.settings.to_h)
14
- rescue LoadError => _
15
- # Dynflow core is not available in the proxy, will be handled
16
- # by standalone Dynflow core
17
- end
18
- end
11
+ load_classes ::Proxy::Ansible::ConfigurationLoader
12
+ load_validators :validate_settings => ::Proxy::Ansible::ValidateSettings
13
+ validate :validate!, :validate_settings => nil
19
14
  end
20
15
  end
21
16
  end
@@ -0,0 +1,44 @@
1
+ module Proxy
2
+ module Ansible
3
+ # Helper for Playbooks Reader
4
+ class ReaderHelper
5
+ class << self
6
+ DEFAULT_COLLECTIONS_PATHS = '/etc/ansible/collections:/usr/share/ansible/collections'.freeze
7
+ DEFAULT_CONFIG_FILE = '/etc/ansible/ansible.cfg'.freeze
8
+
9
+ def collections_paths
10
+ config_path(path_from_config('collections_paths'), DEFAULT_COLLECTIONS_PATHS)
11
+ end
12
+
13
+ def config_path(config_line, default)
14
+ return default if config_line.empty?
15
+
16
+ config_line_key = config_line.first.split('=').first.strip
17
+ # In case of commented roles_path key "#roles_path" or #collections_paths, return default
18
+ return default if ['#roles_path', '#collections_paths'].include?(config_line_key)
19
+
20
+ config_line.first.split('=').last.strip
21
+ end
22
+
23
+ def path_from_config(config_key)
24
+ File.readlines(DEFAULT_CONFIG_FILE).select do |line|
25
+ line =~ /^\s*#{config_key}/
26
+ end
27
+ rescue Errno::ENOENT, Errno::EACCES => e
28
+ RolesReader.logger.debug(e.backtrace)
29
+ message = "Could not read Ansible config file #{DEFAULT_CONFIG_FILE} - #{e.message}"
30
+ raise ReadConfigFileException.new(message), message
31
+ end
32
+
33
+ def playbook_or_role_full_name(path)
34
+ parts = path.split('/')
35
+ playbook = parts.pop
36
+ parts.pop
37
+ collection = parts.pop
38
+ author = parts.pop
39
+ "#{author}.#{collection}.#{playbook}"
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'smart_proxy_dynflow/runner/command_runner'
4
+
5
+ module Proxy::Ansible
6
+ module RemoteExecutionCore
7
+ # Takes an inventory and runs it through REXCore CommandRunner
8
+ class AnsibleRunner < ::Proxy::Dynflow::Runner::CommandRunner
9
+ DEFAULT_REFRESH_INTERVAL = 1
10
+ CONNECTION_PROMPT = 'Are you sure you want to continue connecting (yes/no)? '
11
+
12
+ def initialize(options, suspended_action:)
13
+ super(options, :suspended_action => suspended_action)
14
+ @playbook_runner = Proxy::Ansible::Runner::Playbook.new(
15
+ options['ansible_inventory'],
16
+ options['script'],
17
+ options,
18
+ :suspended_action => suspended_action
19
+ )
20
+ end
21
+
22
+ def start
23
+ @playbook_runner.logger = logger
24
+ @playbook_runner.start
25
+ rescue StandardError => e
26
+ logger.error(
27
+ 'error while initalizing command'\
28
+ " #{e.class} #{e.message}:\n #{e.backtrace.join("\n")}"
29
+ )
30
+ publish_exception('Error initializing command', e)
31
+ end
32
+
33
+ def fill_continuous_output(continuous_output)
34
+ delegated_output.fetch('result', []).each do |raw_output|
35
+ continuous_output.add_raw_output(raw_output)
36
+ end
37
+ rescue StandardError => e
38
+ continuous_output.add_exception(_('Error loading data from proxy'), e)
39
+ end
40
+
41
+ def refresh
42
+ @command_out = @playbook_runner.command_out
43
+ @command_in = @playbook_runner.command_in
44
+ @command_pid = @playbook_runner.command_pid
45
+ super
46
+ kill if unknown_host_key_fingerprint?
47
+ end
48
+
49
+ def kill
50
+ publish_exit_status(1)
51
+ ::Process.kill('SIGTERM', @command_pid)
52
+ close
53
+ end
54
+
55
+ private
56
+
57
+ def unknown_host_key_fingerprint?
58
+ last_output = @continuous_output.raw_outputs.last
59
+ return if last_output.nil?
60
+ last_output['output']&.lines&.last == CONNECTION_PROMPT
61
+ end
62
+ end
63
+ end
64
+ end
@@ -5,33 +5,16 @@ module Proxy
5
5
  # Implements the logic needed to read the roles and associated information
6
6
  class RolesReader
7
7
  class << self
8
- DEFAULT_CONFIG_FILE = '/etc/ansible/ansible.cfg'.freeze
9
8
  DEFAULT_ROLES_PATH = '/etc/ansible/roles:/usr/share/ansible/roles'.freeze
10
- DEFAULT_COLLECTIONS_PATHS = '/etc/ansible/collections:/usr/share/ansible/collections'.freeze
11
9
 
12
10
  def list_roles
13
11
  roles = roles_path.split(':').map { |path| read_roles(path) }.flatten
14
- collection_roles = collections_paths.split(':').map { |path| read_collection_roles(path) }.flatten
12
+ collection_roles = ReaderHelper.collections_paths.split(':').map { |path| read_collection_roles(path) }.flatten
15
13
  roles + collection_roles
16
14
  end
17
15
 
18
16
  def roles_path
19
- config_path(path_from_config('roles_path'), DEFAULT_ROLES_PATH)
20
- end
21
-
22
- def collections_paths
23
- config_path(path_from_config('collections_paths'), DEFAULT_COLLECTIONS_PATHS)
24
- end
25
-
26
- def config_path(config_line, default)
27
- # Default to /etc/ansible/roles if config_line is empty
28
- return default if config_line.empty?
29
-
30
- config_line_key = config_line.first.split('=').first.strip
31
- # In case of commented roles_path key "#roles_path" or #collections_paths, return default
32
- return default if ['#roles_path', '#collections_paths'].include?(config_line_key)
33
-
34
- config_line.first.split('=').last.strip
17
+ ReaderHelper.config_path(ReaderHelper.path_from_config('roles_path'), DEFAULT_ROLES_PATH)
35
18
  end
36
19
 
37
20
  def logger
@@ -58,33 +41,17 @@ module Proxy
58
41
 
59
42
  def glob_path(path)
60
43
  Dir.glob path
61
-
62
44
  end
63
45
 
64
46
  def read_collection_roles(collections_path)
65
47
  Dir.glob("#{collections_path}/ansible_collections/*/*/roles/*").map do |path|
66
- parts = path.split('/')
67
- role = parts.pop
68
- parts.pop
69
- collection = parts.pop
70
- author = parts.pop
71
- "#{author}.#{collection}.#{role}"
48
+ ReaderHelper.playbook_or_role_full_name(path)
72
49
  end
73
50
  rescue Errno::ENOENT, Errno::EACCES => e
74
51
  logger.debug(e.backtrace)
75
52
  message = "Could not read Ansible roles #{collections_path} - #{e.message}"
76
53
  raise ReadRolesException.new(message), message
77
54
  end
78
-
79
- def path_from_config(config_key)
80
- File.readlines(DEFAULT_CONFIG_FILE).select do |line|
81
- line =~ /^\s*#{config_key}/
82
- end
83
- rescue Errno::ENOENT, Errno::EACCES => e
84
- logger.debug(e.backtrace)
85
- message = "Could not read Ansible config file #{DEFAULT_CONFIG_FILE} - #{e.message}"
86
- raise ReadConfigFileException.new(message), message
87
- end
88
55
  end
89
56
  end
90
57
  end
@@ -0,0 +1,253 @@
1
+ require 'shellwords'
2
+ require 'yaml'
3
+
4
+ require 'smart_proxy_dynflow/runner/command'
5
+ require 'smart_proxy_dynflow/runner/base'
6
+ require 'smart_proxy_dynflow/runner/parent'
7
+ module Proxy::Ansible
8
+ module Runner
9
+ class AnsibleRunner < ::Proxy::Dynflow::Runner::Parent
10
+ include ::Proxy::Dynflow::Runner::Command
11
+ attr_reader :execution_timeout_interval, :command_pid
12
+
13
+ def initialize(input, suspended_action:)
14
+ super input, :suspended_action => suspended_action
15
+ @inventory = rebuild_secrets(rebuild_inventory(input), input)
16
+ action_input = input.values.first[:input][:action_input]
17
+ @playbook = action_input[:script]
18
+ @root = working_dir
19
+ @verbosity_level = action_input[:verbosity_level]
20
+ @rex_command = action_input[:remote_execution_command]
21
+ @check_mode = action_input[:check_mode]
22
+ @tags = action_input[:tags]
23
+ @tags_flag = action_input[:tags_flag]
24
+ @passphrase = action_input['secrets']['key_passphrase']
25
+ @execution_timeout_interval = action_input[:execution_timeout_interval]
26
+ @cleanup_working_dirs = action_input.fetch(:cleanup_working_dirs, true)
27
+ end
28
+
29
+ def start
30
+ prepare_directory_structure
31
+ write_inventory
32
+ write_playbook
33
+ write_ssh_key if !@passphrase.nil? && !@passphrase.empty?
34
+ start_ansible_runner
35
+ end
36
+
37
+ def refresh
38
+ return unless super
39
+ @counter ||= 1
40
+ @uuid ||= File.basename(Dir["#{@root}/artifacts/*"].first)
41
+ job_event_dir = File.join(@root, 'artifacts', @uuid, 'job_events')
42
+ loop do
43
+ files = Dir["#{job_event_dir}/*.json"].map do |file|
44
+ num = File.basename(file)[/\A\d+/].to_i unless file.include?('partial')
45
+ [file, num]
46
+ end
47
+ files_with_nums = files.select { |(_, num)| num && num >= @counter }.sort_by(&:last)
48
+ break if files_with_nums.empty?
49
+ logger.debug("[foreman_ansible] - processing event files: #{files_with_nums.map(&:first).inspect}}")
50
+ files_with_nums.map(&:first).each { |event_file| handle_event_file(event_file) }
51
+ @counter = files_with_nums.last.last + 1
52
+ end
53
+ end
54
+
55
+ def timeout
56
+ logger.debug('job timed out')
57
+ super
58
+ end
59
+
60
+ def timeout_interval
61
+ execution_timeout_interval
62
+ end
63
+
64
+ def kill
65
+ ::Process.kill('SIGTERM', @command_pid)
66
+ publish_exit_status(2)
67
+ @inventory['all']['hosts'].each { |hostname| @exit_statuses[hostname] = 2 }
68
+ broadcast_data('Timeout for execution passed, stopping the job', 'stderr')
69
+ close
70
+ end
71
+
72
+ def close
73
+ super
74
+ FileUtils.remove_entry(@root) if @tmp_working_dir && Dir.exist?(@root) && @cleanup_working_dirs
75
+ end
76
+
77
+ private
78
+
79
+ def handle_event_file(event_file)
80
+ logger.debug("[foreman_ansible] - parsing event file #{event_file}")
81
+ begin
82
+ event = JSON.parse(File.read(event_file))
83
+ if (hostname = event.dig('event_data', 'host'))
84
+ handle_host_event(hostname, event)
85
+ else
86
+ handle_broadcast_data(event)
87
+ end
88
+ true
89
+ rescue JSON::ParserError => e
90
+ logger.error("[foreman_ansible] - Error parsing runner event at #{event_file}: #{e.class}: #{e.message}")
91
+ logger.debug(e.backtrace.join("\n"))
92
+ end
93
+ end
94
+
95
+ def handle_host_event(hostname, event)
96
+ log_event("for host: #{hostname.inspect}", event)
97
+ publish_data_for(hostname, event['stdout'] + "\n", 'stdout') if event['stdout']
98
+ case event['event']
99
+ when 'runner_on_ok'
100
+ publish_exit_status_for(hostname, 0) if @exit_statuses[hostname].nil?
101
+ when 'runner_on_unreachable'
102
+ publish_exit_status_for(hostname, 1)
103
+ when 'runner_on_failed'
104
+ publish_exit_status_for(hostname, 2) if event.dig('event_data', 'ignore_errors').nil?
105
+ end
106
+ end
107
+
108
+ def handle_broadcast_data(event)
109
+ log_event("broadcast", event)
110
+ if event['event'] == 'playbook_on_stats'
111
+ failures = event.dig('event_data', 'failures') || {}
112
+ unreachable = event.dig('event_data', 'dark') || {}
113
+ header, *rows = event['stdout'].strip.lines.map(&:chomp)
114
+ @outputs.keys.select { |key| key.is_a? String }.each do |host|
115
+ line = rows.find { |row| row =~ /#{host}/ }
116
+ publish_data_for(host, [header, line].join("\n"), 'stdout')
117
+
118
+ # If the task has been rescued, it won't consider a failure
119
+ if @exit_statuses[host].to_i != 0 && failures[host].to_i <= 0 && unreachable[host].to_i <= 0
120
+ publish_exit_status_for(host, 0)
121
+ end
122
+ end
123
+ else
124
+ broadcast_data(event['stdout'] + "\n", 'stdout')
125
+ end
126
+ end
127
+
128
+ def write_inventory
129
+ path = File.join(@root, 'inventory', 'hosts')
130
+ data_path = File.join(@root, 'data')
131
+ inventory_script = <<~INVENTORY_SCRIPT
132
+ #!/bin/sh
133
+ cat #{::Shellwords.escape data_path}
134
+ INVENTORY_SCRIPT
135
+ File.write(path, inventory_script)
136
+ File.write(data_path, JSON.dump(@inventory))
137
+ File.chmod(0o0755, path)
138
+ end
139
+
140
+ def write_playbook
141
+ File.write(File.join(@root, 'project', 'playbook.yml'), @playbook)
142
+ end
143
+
144
+ def write_ssh_key
145
+ key_path = File.join(@root, 'env', 'ssh_key')
146
+ File.symlink(File.expand_path(Proxy::RemoteExecution::Ssh::Plugin.settings[:ssh_identity_key_file]), key_path)
147
+
148
+ passwords_path = File.join(@root, 'env', 'passwords')
149
+ # here we create a secrets file for ansible-runner, which uses the key as regexp
150
+ # to match line asking for password, given the limitation to match only first 100 chars
151
+ # and the fact the line contains dynamically created temp directory, the regexp
152
+ # mentions only things that are always there, such as artifacts directory and the key name
153
+ secrets = YAML.dump({ "for.*/artifacts/.*/ssh_key_data:" => @passphrase })
154
+ File.write(passwords_path, secrets, perm: 0o600)
155
+ end
156
+
157
+ def start_ansible_runner
158
+ env = {}
159
+ env['FOREMAN_CALLBACK_DISABLE'] = '1' if @rex_command
160
+ command = [env, 'ansible-runner', 'run', @root, '-p', 'playbook.yml']
161
+ command << '--cmdline' << cmdline unless cmdline.nil?
162
+ command << verbosity if verbose?
163
+ initialize_command(*command)
164
+ logger.debug("[foreman_ansible] - Running command '#{command.join(' ')}'")
165
+ end
166
+
167
+ def cmdline
168
+ cmd_args = [tags_cmd, check_cmd].reject(&:empty?)
169
+ return nil unless cmd_args.any?
170
+ cmd_args.join(' ')
171
+ end
172
+
173
+ def tags_cmd
174
+ flag = @tags_flag == 'include' ? '--tags' : '--skip-tags'
175
+ @tags.empty? ? '' : "#{flag} '#{Array(@tags).join(',')}'"
176
+ end
177
+
178
+ def check_cmd
179
+ check_mode? ? '"--check"' : ''
180
+ end
181
+
182
+ def verbosity
183
+ '-' + 'v' * @verbosity_level.to_i
184
+ end
185
+
186
+ def verbose?
187
+ @verbosity_level.to_i.positive?
188
+ end
189
+
190
+ def check_mode?
191
+ @check_mode == true && @rex_command == false
192
+ end
193
+
194
+ def prepare_directory_structure
195
+ inner = %w[inventory project env].map { |part| File.join(@root, part) }
196
+ ([@root] + inner).each do |path|
197
+ FileUtils.mkdir_p path
198
+ end
199
+ end
200
+
201
+ def log_event(description, event)
202
+ # TODO: replace this ugly code with block variant once https://github.com/Dynflow/dynflow/pull/323
203
+ # arrives in production
204
+ logger.debug("[foreman_ansible] - handling event #{description}: #{JSON.pretty_generate(event)}") if logger.level <= ::Logger::DEBUG
205
+ end
206
+
207
+ # Each per-host task has inventory only for itself, we must
208
+ # collect all the partial inventories into one large inventory
209
+ # containing all the hosts.
210
+ def rebuild_inventory(input)
211
+ action_inputs = input.values.map { |hash| hash[:input][:action_input] }
212
+ hostnames = action_inputs.map { |hash| hash[:name] }
213
+ inventories = action_inputs.map { |hash| hash[:ansible_inventory] }
214
+ host_vars = inventories.map { |i| i['_meta']['hostvars'] }.reduce({}) do |acc, hosts|
215
+ hosts.reduce(acc) do |inner_acc, (hostname, vars)|
216
+ vars[:ansible_ssh_private_key_file] ||= Proxy::RemoteExecution::Ssh::Plugin.settings[:ssh_identity_key_file]
217
+ inner_acc.merge(hostname => vars)
218
+ end
219
+ end
220
+
221
+ { '_meta' => { 'hostvars' => host_vars },
222
+ 'all' => { 'hosts' => hostnames,
223
+ 'vars' => inventories.first['all']['vars'] } }
224
+ end
225
+
226
+ def working_dir
227
+ return @root if @root
228
+ dir = Proxy::Ansible::Plugin.settings[:working_dir]
229
+ @tmp_working_dir = true
230
+ if dir.nil?
231
+ Dir.mktmpdir
232
+ else
233
+ Dir.mktmpdir(nil, File.expand_path(dir))
234
+ end
235
+ end
236
+
237
+ def rebuild_secrets(inventory, input)
238
+ input.each do |host, host_input|
239
+ secrets = host_input['input']['action_input']['secrets']
240
+ per_host = secrets['per-host'][host]
241
+
242
+ new_secrets = {
243
+ 'ansible_password' => inventory['ssh_password'] || per_host['ansible_password'],
244
+ 'ansible_become_password' => inventory['effective_user_password'] || per_host['ansible_become_password']
245
+ }
246
+ inventory['_meta']['hostvars'][host].update(new_secrets)
247
+ end
248
+
249
+ inventory
250
+ end
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proxy::Ansible
4
+ # Creates the actual command to be passed to smart_proxy_dynflow to run
5
+ class CommandCreator
6
+ def initialize(inventory_file, playbook_file, options = {})
7
+ @options = options
8
+ @playbook_file = playbook_file
9
+ @inventory_file = inventory_file
10
+ command
11
+ end
12
+
13
+ def command
14
+ parts = [environment_variables]
15
+ parts << 'ansible-playbook'
16
+ parts.concat(command_options)
17
+ parts << @playbook_file
18
+ parts
19
+ end
20
+
21
+ private
22
+
23
+ def environment_variables
24
+ defaults = { 'JSON_INVENTORY_FILE' => @inventory_file }
25
+ defaults['ANSIBLE_CALLBACK_WHITELIST'] = '' if rex_command?
26
+ defaults
27
+ end
28
+
29
+ def command_options
30
+ opts = ['-i', json_inventory_script]
31
+ opts.concat([setup_verbosity]) if verbose?
32
+ opts.concat(['-T', @options[:timeout]]) unless @options[:timeout].nil?
33
+ opts
34
+ end
35
+
36
+ def json_inventory_script
37
+ File.expand_path('../../../bin/json_inventory.sh', File.dirname(__FILE__))
38
+ end
39
+
40
+ def setup_verbosity
41
+ verbosity_level = @options[:verbosity_level].to_i
42
+ verbosity = '-'
43
+ verbosity_level.times do
44
+ verbosity += 'v'
45
+ end
46
+ verbosity
47
+ end
48
+
49
+ def verbose?
50
+ verbosity_level = @options[:verbosity_level]
51
+ # rubocop:disable Rails/Present
52
+ !verbosity_level.nil? && !verbosity_level.empty? &&
53
+ verbosity_level.to_i.positive?
54
+ # rubocop:enable Rails/Present
55
+ end
56
+
57
+ def rex_command?
58
+ @options[:remote_execution_command]
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'smart_proxy_dynflow/continuous_output'
4
+ require 'smart_proxy_dynflow/runner/command_runner'
5
+ require_relative 'command_creator'
6
+ require 'tmpdir'
7
+ require 'net/ssh'
8
+
9
+ module Proxy::Ansible
10
+ module Runner
11
+ # Implements Proxy::Dynflow::Runner::Base interface for running
12
+ # Ansible playbooks, used by the Foreman Ansible plugin and Ansible proxy
13
+ class Playbook < Proxy::Dynflow::Runner::CommandRunner
14
+ attr_reader :command_out, :command_in, :command_pid
15
+
16
+ def initialize(inventory, playbook, options = {}, suspended_action:)
17
+ super :suspended_action => suspended_action
18
+ @inventory = rebuild_secrets(inventory, options[:secrets])
19
+ unknown_hosts.each do |host|
20
+ add_to_known_hosts(host)
21
+ end
22
+ @playbook = playbook
23
+ @options = options
24
+ initialize_dirs
25
+ end
26
+
27
+ def start
28
+ write_inventory
29
+ write_playbook
30
+ command = CommandCreator.new(inventory_file,
31
+ playbook_file,
32
+ @options).command
33
+ logger.debug('[foreman_ansible] - Initializing Ansible Runner')
34
+ Dir.chdir(@ansible_dir) do
35
+ initialize_command(*command)
36
+ logger.debug("[foreman_ansible] - Running command '#{command.join(' ')}'")
37
+ end
38
+ end
39
+
40
+ def kill
41
+ publish_data('== TASK ABORTED BY USER ==', 'stdout')
42
+ publish_exit_status(1)
43
+ ::Process.kill('SIGTERM', @command_pid)
44
+ close
45
+ end
46
+
47
+ def close
48
+ super
49
+ FileUtils.remove_entry(@working_dir) if @tmp_working_dir
50
+ end
51
+
52
+ private
53
+
54
+ def write_inventory
55
+ ensure_directory(File.dirname(inventory_file))
56
+ File.write(inventory_file, JSON.dump(@inventory))
57
+ end
58
+
59
+ def write_playbook
60
+ ensure_directory(File.dirname(playbook_file))
61
+ File.write(playbook_file, @playbook)
62
+ end
63
+
64
+ def inventory_file
65
+ File.join(@working_dir, 'foreman-inventories', id)
66
+ end
67
+
68
+ def playbook_file
69
+ File.join(@working_dir, "foreman-playbook-#{id}.yml")
70
+ end
71
+
72
+ def events_dir
73
+ File.join(@working_dir, 'events', id.to_s)
74
+ end
75
+
76
+ def ensure_directory(path)
77
+ if File.exist?(path)
78
+ raise "#{path} expected to be a directory" unless File.directory?(path)
79
+ else
80
+ FileUtils.mkdir_p(path)
81
+ end
82
+ path
83
+ end
84
+
85
+ def initialize_dirs
86
+ settings = Proxy::Ansible::Plugin.settings
87
+ initialize_working_dir(settings[:working_dir])
88
+ initialize_ansible_dir(settings[:ansible_dir])
89
+ end
90
+
91
+ def initialize_working_dir(working_dir)
92
+ if working_dir.nil?
93
+ @working_dir = Dir.mktmpdir
94
+ @tmp_working_dir = true
95
+ else
96
+ @working_dir = File.expand_path(working_dir)
97
+ end
98
+ end
99
+
100
+ def initialize_ansible_dir(ansible_dir)
101
+ raise "Ansible dir #{ansible_dir} does not exist" unless
102
+ !ansible_dir.nil? && File.exist?(ansible_dir)
103
+ @ansible_dir = ansible_dir
104
+ end
105
+
106
+ def unknown_hosts
107
+ @inventory['all']['hosts'].select do |host|
108
+ Net::SSH::KnownHosts.search_for(host).empty?
109
+ end
110
+ end
111
+
112
+ def add_to_known_hosts(host)
113
+ logger.warn("[foreman_ansible] - Host #{host} not found in known_hosts")
114
+ Net::SSH::Transport::Session.new(host).host_keys.each do |host_key|
115
+ Net::SSH::KnownHosts.add(host, host_key)
116
+ end
117
+ logger.warn("[foreman_ansible] - Added host key #{host} to known_hosts")
118
+ rescue StandardError => e
119
+ logger.error('[foreman_ansible] - Failed to save host key for '\
120
+ "#{host}: #{e}")
121
+ end
122
+
123
+ def rebuild_secrets(inventory, secrets)
124
+ inventory['all']['hosts'].each do |name|
125
+ per_host = secrets['per-host'][name]
126
+
127
+ new_secrets = {
128
+ 'ansible_password' => inventory['ssh_password'] || per_host['ansible_password'],
129
+ 'ansible_become_password' => inventory['effective_user_password'] || per_host['ansible_become_password']
130
+ }
131
+ inventory['_meta']['hostvars'][name].update(new_secrets)
132
+ end
133
+
134
+ inventory
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,48 @@
1
+ require 'fileutils'
2
+ require 'smart_proxy_dynflow/task_launcher/abstract'
3
+ require 'smart_proxy_dynflow/task_launcher/batch'
4
+ require 'smart_proxy_dynflow/task_launcher/group'
5
+ require 'smart_proxy_ansible/runner/ansible_runner'
6
+
7
+ module Proxy::Ansible
8
+ module TaskLauncher
9
+ class AnsibleRunner < Proxy::Dynflow::TaskLauncher::AbstractGroup
10
+ def runner_input(input)
11
+ super(input).reduce({}) do |acc, (_id, data)|
12
+ acc.merge(data[:input]['action_input']['name'] => data)
13
+ end
14
+ end
15
+
16
+ def operation
17
+ 'ansible-runner'
18
+ end
19
+
20
+ def self.runner_class
21
+ Runner::AnsibleRunner
22
+ end
23
+
24
+ # Discard everything apart from hostname to be able to tell the actions
25
+ # apart when debugging
26
+ def transform_input(input)
27
+ { 'action_input' => super['action_input'].slice('name', :task_id) }
28
+ end
29
+
30
+ # def self.input_format
31
+ # {
32
+ # $UUID => {
33
+ # :execution_plan_id => $EXECUTION_PLAN_UUID,
34
+ # :run_step_id => Integer,
35
+ # :input => {
36
+ # :action_class => Class,
37
+ # :action_input => {
38
+ # "ansible_inventory"=> String,
39
+ # "hostname"=>"127.0.0.1",
40
+ # "script"=>"---\n- hosts: all\n tasks:\n - shell: |\n true\n register: out\n - debug: var=out"
41
+ # }
42
+ # }
43
+ # }
44
+ # }
45
+ # end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,25 @@
1
+ require 'smart_proxy_dynflow/action/runner'
2
+
3
+ module Proxy::Ansible
4
+ module TaskLauncher
5
+ class Playbook < Proxy::Dynflow::TaskLauncher::Batch
6
+ class PlaybookRunnerAction < Proxy::Dynflow::Action::Runner
7
+ def initiate_runner
8
+ additional_options = {
9
+ :step_id => run_step_id,
10
+ :uuid => execution_plan_id
11
+ }
12
+ ::Proxy::Ansible::RemoteExecutionCore::AnsibleRunner.new(
13
+ input.merge(additional_options),
14
+ :suspended_action => suspended_action
15
+ )
16
+ end
17
+ end
18
+
19
+ def child_launcher(parent)
20
+ ::Proxy::Dynflow::TaskLauncher::Single.new(world, callback, :parent => parent,
21
+ :action_class_override => PlaybookRunnerAction)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,7 @@
1
+ module Proxy::Ansible
2
+ class ValidateSettings < ::Proxy::PluginValidators::Base
3
+ def validate!(settings)
4
+ raise NotExistingWorkingDirException.new("Working directory does not exist") unless settings[:working_dir].nil? || File.directory?(File.expand_path(settings[:working_dir]))
5
+ end
6
+ end
7
+ end
@@ -2,6 +2,6 @@ module Proxy
2
2
  # Version, this allows the proxy and other plugins know
3
3
  # what version of the Ansible plugin is running
4
4
  module Ansible
5
- VERSION = '3.1.0'
5
+ VERSION = '3.3.0'
6
6
  end
7
7
  end
@@ -4,8 +4,12 @@ module Proxy
4
4
  # Basic requires for this plugin
5
5
  module Ansible
6
6
  require 'smart_proxy_ansible/version'
7
+ require 'smart_proxy_ansible/configuration_loader'
8
+ require 'smart_proxy_ansible/validate_settings'
7
9
  require 'smart_proxy_ansible/plugin'
8
10
  require 'smart_proxy_ansible/roles_reader'
11
+ require 'smart_proxy_ansible/playbooks_reader'
12
+ require 'smart_proxy_ansible/reader_helper'
9
13
  require 'smart_proxy_ansible/variables_extractor'
10
14
  end
11
15
  end
@@ -1,3 +1,3 @@
1
1
  ---
2
2
  :enabled: true
3
- :ansible_working_dir: '~/.foreman-ansible'
3
+ :working_dir: '~/.foreman-ansible'
metadata CHANGED
@@ -1,15 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smart_proxy_ansible
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.0
4
+ version: 3.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ivan Nečas
8
8
  - Daniel Lobato
9
- autorequire:
9
+ autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2021-05-17 00:00:00.000000000 Z
12
+ date: 2022-01-17 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rake
@@ -95,20 +95,48 @@ dependencies:
95
95
  - - ">="
96
96
  - !ruby/object:Gem::Version
97
97
  version: '0'
98
+ - !ruby/object:Gem::Dependency
99
+ name: net-ssh
100
+ requirement: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: '0'
105
+ type: :runtime
106
+ prerelease: false
107
+ version_requirements: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '0'
98
112
  - !ruby/object:Gem::Dependency
99
113
  name: smart_proxy_dynflow
100
114
  requirement: !ruby/object:Gem::Requirement
101
115
  requirements:
102
116
  - - "~>"
103
117
  - !ruby/object:Gem::Version
104
- version: '0.1'
118
+ version: '0.5'
119
+ type: :runtime
120
+ prerelease: false
121
+ version_requirements: !ruby/object:Gem::Requirement
122
+ requirements:
123
+ - - "~>"
124
+ - !ruby/object:Gem::Version
125
+ version: '0.5'
126
+ - !ruby/object:Gem::Dependency
127
+ name: smart_proxy_remote_execution_ssh
128
+ requirement: !ruby/object:Gem::Requirement
129
+ requirements:
130
+ - - "~>"
131
+ - !ruby/object:Gem::Version
132
+ version: '0.4'
105
133
  type: :runtime
106
134
  prerelease: false
107
135
  version_requirements: !ruby/object:Gem::Requirement
108
136
  requirements:
109
137
  - - "~>"
110
138
  - !ruby/object:Gem::Version
111
- version: '0.1'
139
+ version: '0.4'
112
140
  description: " Smart-Proxy ansible plugin\n"
113
141
  email:
114
142
  - inecas@redhat.com
@@ -121,13 +149,25 @@ extra_rdoc_files:
121
149
  files:
122
150
  - LICENSE
123
151
  - README.md
152
+ - bin/json_inventory.sh
124
153
  - bundler.d/ansible.rb
125
154
  - lib/smart_proxy_ansible.rb
155
+ - lib/smart_proxy_ansible/actions.rb
126
156
  - lib/smart_proxy_ansible/api.rb
157
+ - lib/smart_proxy_ansible/configuration_loader.rb
127
158
  - lib/smart_proxy_ansible/exception.rb
128
159
  - lib/smart_proxy_ansible/http_config.ru
160
+ - lib/smart_proxy_ansible/playbooks_reader.rb
129
161
  - lib/smart_proxy_ansible/plugin.rb
162
+ - lib/smart_proxy_ansible/reader_helper.rb
163
+ - lib/smart_proxy_ansible/remote_execution_core/ansible_runner.rb
130
164
  - lib/smart_proxy_ansible/roles_reader.rb
165
+ - lib/smart_proxy_ansible/runner/ansible_runner.rb
166
+ - lib/smart_proxy_ansible/runner/command_creator.rb
167
+ - lib/smart_proxy_ansible/runner/playbook.rb
168
+ - lib/smart_proxy_ansible/task_launcher/ansible_runner.rb
169
+ - lib/smart_proxy_ansible/task_launcher/playbook.rb
170
+ - lib/smart_proxy_ansible/validate_settings.rb
131
171
  - lib/smart_proxy_ansible/variables_extractor.rb
132
172
  - lib/smart_proxy_ansible/version.rb
133
173
  - settings.d/ansible.yml.example
@@ -135,7 +175,7 @@ homepage: https://github.com/theforeman/smart_proxy_ansible
135
175
  licenses:
136
176
  - GPL-3.0
137
177
  metadata: {}
138
- post_install_message:
178
+ post_install_message:
139
179
  rdoc_options: []
140
180
  require_paths:
141
181
  - lib
@@ -150,8 +190,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
150
190
  - !ruby/object:Gem::Version
151
191
  version: '0'
152
192
  requirements: []
153
- rubygems_version: 3.1.2
154
- signing_key:
193
+ rubygems_version: 3.3.4
194
+ signing_key:
155
195
  specification_version: 4
156
196
  summary: Smart-Proxy Ansible plugin
157
197
  test_files: []