smart_proxy_ansible 3.1.0 → 3.3.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: 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: []