lib/ansible/runner.rb
module Ansible
class Runner
class << self
def available?
return @available if defined?(@available)
@available = system("which ansible-runner >/dev/null 2>&1")
end
# Runs a playbook via ansible-runner, see: https://ansible-runner.readthedocs.io/en/latest/standalone.html#running-playbooks
#
# @param env_vars [Hash] Hash with key/value pairs that will be passed as environment variables to the
# ansible-runner run
# @param extra_vars [Hash] Hash with key/value pairs that will be passed as extra_vars to the ansible-runner run
# @param playbook_path [String] Path to the playbook we will want to run
# @param hosts [Array] List of hostnames to target with the playbook
# @param credentials [Array] List of Authentication object ids to provide to the playbook run
# @param verbosity [Integer] ansible-runner verbosity level 0-5
# @return [Ansible::Runner::ResponseAsync] Response object that we can query for .running?, providing us the
# Ansible::Runner::Response object, when the job is finished.
def run_async(env_vars, extra_vars, playbook_path, hosts: ["localhost"], credentials: [], verbosity: 0, become_enabled: false)
run_via_cli(hosts,
credentials,
env_vars,
extra_vars,
:ansible_runner_method => "start",
:playbook => playbook_path,
:verbosity => verbosity,
:become_enabled => become_enabled)
end
# Runs a role directly via ansible-runner, a simple playbook is then automatically created,
# see: https://ansible-runner.readthedocs.io/en/latest/standalone.html#running-roles-directly
#
# @param env_vars [Hash] Hash with key/value pairs that will be passed as environment variables to the
# ansible-runner run
# @param extra_vars [Hash] Hash with key/value pairs that will be passed as extra_vars to the ansible-runner run
# @param role_name [String] Ansible role name
# @param roles_path [String] Path to the directory with roles
# @param role_skip_facts [Boolean] Whether we should skip facts gathering, equals to 'gather_facts: False' in a
# playbook. True by default.
# @param hosts [Array] List of hostnames to target with the role
# @param credentials [Array] List of Authentication object ids to provide to the role run
# @param verbosity [Integer] ansible-runner verbosity level 0-5
# @return [Ansible::Runner::ResponseAsync] Response object that we can query for .running?, providing us the
# Ansible::Runner::Response object, when the job is finished.
def run_role_async(env_vars, extra_vars, role_name, roles_path:, role_skip_facts: true, hosts: ["localhost"], credentials: [], verbosity: 0, become_enabled: false)
run_via_cli(hosts,
credentials,
env_vars,
extra_vars,
:ansible_runner_method => "start",
:role => role_name,
:roles_path => roles_path,
:role_skip_facts => role_skip_facts,
:verbosity => verbosity,
:become_enabled => become_enabled)
end
# Runs a playbook via ansible-runner, see: https://ansible-runner.readthedocs.io/en/latest/standalone.html#running-playbooks
#
# @param env_vars [Hash] Hash with key/value pairs that will be passed as environment variables to the
# ansible-runner run
# @param extra_vars [Hash] Hash with key/value pairs that will be passed as extra_vars to the ansible-runner run
# @param playbook_path [String] Path to the playbook we will want to run
# @param tags [Hash] Hash with key/values pairs that will be passed as tags to the ansible-runner run
# @param hosts [Array] List of hostnames to target with the playbook
# @param credentials [Array] List of Authentication object ids to provide to the playbook run
# @param verbosity [Integer] ansible-runner verbosity level 0-5
# @return [Ansible::Runner::Response] Response object with all details about the ansible run
def run(env_vars, extra_vars, playbook_path, tags: nil, hosts: ["localhost"], credentials: [], verbosity: 0, become_enabled: false)
run_via_cli(hosts,
credentials,
env_vars,
extra_vars,
:tags => tags,
:playbook => playbook_path,
:verbosity => verbosity,
:become_enabled => become_enabled)
end
# Runs a role directly via ansible-runner, a simple playbook is then automatically created,
# see: https://ansible-runner.readthedocs.io/en/latest/standalone.html#running-roles-directly
#
# @param env_vars [Hash] Hash with key/value pairs that will be passed as environment variables to the
# ansible-runner run
# @param extra_vars [Hash] Hash with key/value pairs that will be passed as extra_vars to the ansible-runner run
# @param role_name [String] Ansible role name
# @param roles_path [String] Path to the directory with roles
# @param role_skip_facts [Boolean] Whether we should skip facts gathering, equals to 'gather_facts: False' in a
# playbook. True by default.
# @param tags [Hash] Hash with key/values pairs that will be passed as tags to the ansible-runner run
# @param hosts [Array] List of hostnames to target with the role
# @param credentials [Array] List of Authentication object ids to provide to the role run
# @param verbosity [Integer] ansible-runner verbosity level 0-5
# @return [Ansible::Runner::Response] Response object with all details about the ansible run
def run_role(env_vars, extra_vars, role_name, roles_path:, role_skip_facts: true, tags: nil, hosts: ["localhost"], credentials: [], verbosity: 0, become_enabled: false)
run_via_cli(hosts,
credentials,
env_vars,
extra_vars,
:tags => tags,
:role => role_name,
:roles_path => roles_path,
:role_skip_facts => role_skip_facts,
:verbosity => verbosity,
:become_enabled => become_enabled)
end
# Runs "run" method via queue
#
# @param env_vars [Hash] Hash with key/value pairs that will be passed as environment variables to the
# ansible-runner run
# @param extra_vars [Hash] Hash with key/value pairs that will be passed as extra_vars to the ansible-runner run
# @param playbook_path [String] Path to the playbook we will want to run
# @param user_id [String] Current user identifier
# @param queue_opts [Hash] Additional options that will be passed to MiqQueue record creation
# @param hosts [Array] List of hostnames to target with the playbook
# @param credentials [Array] List of Authentication object ids to provide to the playbook run
# @param verbosity [Integer] ansible-runner verbosity level 0-5
# @return [BigInt] ID of MiqTask record wrapping the task
def run_queue(env_vars, extra_vars, playbook_path, user_id, queue_opts, hosts: ["localhost"], credentials: [], verbosity: 0, become_enabled: false)
kwargs = {
:hosts => hosts,
:credentials => credentials,
:verbosity => verbosity,
:become_enabled => become_enabled
}
run_in_queue("run", user_id, queue_opts, [env_vars, extra_vars, playbook_path, kwargs])
end
# Runs "run_role" method via queue
#
# @param env_vars [Hash] Hash with key/value pairs that will be passed as environment variables to the
# ansible-runner run
# @param extra_vars [Hash] Hash with key/value pairs that will be passed as extra_vars to the ansible-runner run
# @param role_name [String] Ansible role name
# @param user_id [String] Current user identifier
# @param queue_opts [Hash] Additional options that will be passed to MiqQueue record creation
# @param roles_path [String] Path to the directory with roles
# @param role_skip_facts [Boolean] Whether we should skip facts gathering, equals to 'gather_facts: False' in a
# playbook. True by default.
# @param hosts [Array] List of hostnames to target with the role
# @param credentials [Array] List of Authentication object ids to provide to the role run
# @param verbosity [Integer] ansible-runner verbosity level 0-5
# @return [BigInt] ID of MiqTask record wrapping the task
def run_role_queue(env_vars, extra_vars, role_name, user_id, queue_opts, roles_path:, role_skip_facts: true, hosts: ["localhost"], credentials: [], verbosity: 0, become_enabled: false)
kwargs = {
:roles_path => roles_path,
:role_skip_facts => role_skip_facts,
:hosts => hosts,
:credentials => credentials,
:verbosity => verbosity,
:become_enabled => become_enabled
}
run_in_queue("run_role", user_id, queue_opts, [env_vars, extra_vars, role_name, kwargs])
end
private
# Run a method on self class, via queue, executed by generic worker
#
# @param method_name [String] A public method name on self
# @param user_id [String] Current user identifier
# @param queue_opts [Hash] Additional options that will be passed to MiqQueue record creation
# @param args [Array] Arguments that will be passed to the <method_name> method
# @return [BigInt] ID of MiqTask record wrapping the task
def run_in_queue(method_name, user_id, queue_opts, args)
queue_opts = {
:args => args,
:queue_name => "generic",
:class_name => name,
:method_name => method_name,
}.merge(queue_opts)
task_opts = {
:action => "Run Ansible Playbook",
:userid => user_id,
}
MiqTask.generic_action_with_callback(task_opts, queue_opts)
end
# Runs a playbook or a role via ansible-runner.
#
# @param hosts [Array] List of hostnames to target
# @param credentials [Array] List of Authentication object ids to provide to the run
# @param env_vars [Hash] Hash with key/value pairs that will be passed as environment variables to the
# ansible-runner run
# @param extra_vars [Hash] Hash with key/value pairs that will be passed as extra_vars to the ansible-runner run
# @param tags [Hash] Hash with key/values pairs that will be passed as tags to the ansible-runner run
# @param ansible_runner_method [String] Optional method we will use to run the ansible-runner. It can be either
# "run", which is sync call, or "start" which is async call. Default is "run"
# @param verbosity [Integer] ansible-runner verbosity level 0-5
# @param playbook_or_role_args [Hash] Hash that includes the :playbook key or :role keys
# @return [Ansible::Runner::Response] Response object with all details about the ansible run
def run_via_cli(hosts, credentials, env_vars, extra_vars, tags: nil, ansible_runner_method: "run", verbosity: 0, become_enabled: false, **playbook_or_role_args)
# If we are running against only localhost and no other value is set for ansible_connection
# then assume we don't want to ssh locally
extra_vars["ansible_connection"] ||= "local" if hosts == ["localhost"]
validate_params!(env_vars, extra_vars, tags, ansible_runner_method, playbook_or_role_args)
base_dir = Pathname.new(Dir.mktmpdir("ansible-runner")).realpath
debug = verbosity.to_i >= 5 || env_vars["ANSIBLE_KEEP_REMOTE_FILES"]
cred_command_line, cred_env_vars, cred_extra_vars = credentials_info(credentials, base_dir)
command_line_hash = tags.present? ? {:tags => tags} : {}
if become_enabled
command_line_hash[:become] = nil
end
command_line_hash.merge!(cred_command_line)
env_vars_hash = env_vars.merge(cred_env_vars).merge(runner_env)
extra_vars_hash = extra_vars.merge(cred_extra_vars)
create_hosts_file(base_dir, hosts)
create_extra_vars_file(base_dir, extra_vars_hash)
create_cmdline_file(base_dir, command_line_hash)
params = runner_params(base_dir, ansible_runner_method, playbook_or_role_args, verbosity)
begin
fetch_galaxy_roles(playbook_or_role_args)
result = if async?(ansible_runner_method)
wait_for(base_dir, "pid") { AwesomeSpawn.run("ansible-runner", :env => env_vars_hash, :params => params) }
else
AwesomeSpawn.run("ansible-runner", :env => env_vars_hash, :params => params)
end
res = response(base_dir, ansible_runner_method, result, debug)
ensure
# Clean up the tmp dir for the sync method, for async we will clean it up after the job is finished and we've
# read the output, that will be written into this directory.
res&.cleanup_filesystem! unless async?(ansible_runner_method)
end
end
# @param base_dir [String] ansible-runner private_data_dir parameter
# @param ansible_runner_method [String] Method we will use to run the ansible-runner. It can be either "run",
# which is sync call, or "start" which is async call
# @param result [AwesomeSpawn::CommandResult] Result object of AwesomeSpawn.run
# @return [Ansible::Runner::ResponseAsync, Ansible::Runner::Response] response or ResponseAsync based on the
# ansible_runner_method
def response(base_dir, ansible_runner_method, result, debug)
if async?(ansible_runner_method)
Ansible::Runner::ResponseAsync.new(
:base_dir => base_dir,
:command_line => result.command_line,
:debug => debug
)
else
Ansible::Runner::Response.new(
:base_dir => base_dir,
:command_line => result.command_line,
:stdout => result.output,
:stderr => result.error,
:debug => debug
)
end
end
# @return [Boolean] True if ansible-runner will run on background
def async?(ansible_runner_method)
ansible_runner_method == "start"
end
def runner_params(base_dir, ansible_runner_method, playbook_or_role_args, verbosity)
runner_args = playbook_or_role_args.dup
runner_args.delete(:roles_path) if runner_args[:roles_path].nil?
runner_args[:role_skip_facts] = nil if runner_args.delete(:role_skip_facts)
runner_args[:ident] = "result"
playbook = runner_args.delete(:playbook)
if playbook
runner_args[:playbook] = File.basename(playbook)
runner_args[:project_dir] = File.dirname(playbook)
end
if verbosity.to_i > 0
v_flag = "-#{"v" * verbosity.to_i.clamp(1, 5)}"
runner_args[v_flag] = nil
end
[ansible_runner_method, base_dir, :json, runner_args]
end
# Asserts passed parameters are correct, if not throws an exception.
def validate_params!(env_vars, extra_vars, tags, ansible_runner_method, playbook_or_role_args)
errors = []
errors << "env_vars must be a Hash, got: #{hash.class}" unless env_vars.kind_of?(Hash)
errors << "extra_vars must be a Hash, got: #{hash.class}" unless extra_vars.kind_of?(Hash)
errors << "tags must be a String, got: #{tags.class}" if tags.present? && !tags.kind_of?(String)
unless %w[run start].include?(ansible_runner_method.to_s)
errors << "ansible_runner_method must be 'run' or 'start'"
end
unless playbook_or_role_args.keys == %i[playbook] || playbook_or_role_args.keys.sort == %i[role role_skip_facts roles_path]
errors << "Unexpected playbook/role args: #{playbook_or_role_args}"
end
playbook = playbook_or_role_args[:playbook]
errors << "playbook path doesn't exist: #{playbook}" if playbook && !File.exist?(playbook)
roles_path = playbook_or_role_args[:roles_path]
errors << "roles path doesn't exist: #{roles_path}" if roles_path && !File.exist?(roles_path)
raise ArgumentError, errors.join("; ") if errors.any?
end
def fetch_galaxy_roles(playbook_or_role_args)
return unless playbook_or_role_args[:playbook]
playbook_dir = File.dirname(playbook_or_role_args[:playbook])
Ansible::Content.new(playbook_dir).fetch_galaxy_roles
end
def runner_env
{"PYTHONPATH" => python_path}.delete_nils
end
def credentials_info(credentials, base_dir)
command_line = {}
env_vars = {}
extra_vars = {}
credentials.each do |id|
cred = Ansible::Runner::Credential.new(id, base_dir)
command_line.merge!(cred.command_line)
env_vars.merge!(cred.env_vars)
extra_vars.merge!(cred.extra_vars)
cred.write_config_files
end
[command_line, env_vars, extra_vars]
end
def create_hosts_file(dir, hosts)
inventory_dir = File.join(dir, "inventory")
hosts_file = File.join(inventory_dir, "hosts")
FileUtils.mkdir_p(inventory_dir)
File.write(hosts_file, hosts.join("\n"))
end
def create_extra_vars_file(dir, extra_vars)
return if extra_vars.blank?
extra_vars_file = File.join(env_dir(dir), "extravars")
File.write(extra_vars_file, extra_vars.to_json)
end
def create_cmdline_file(dir, cmd_line)
return if cmd_line.blank?
cmd_line_file = File.join(env_dir(dir), "cmdline")
cmd_string = AwesomeSpawn.build_command_line(nil, cmd_line).lstrip
File.write(cmd_line_file, cmd_string)
end
def env_dir(base_dir)
FileUtils.mkdir_p(File.join(base_dir, "env")).first
end
def wait_for(base_dir, target_path, timeout: 10.seconds)
require "listen"
require "concurrent"
path_created = Concurrent::Event.new
listener = Listen.to(base_dir, :only => %r{\A#{target_path}\z}) do |modified, added, _removed|
path_created.set if added.include?(base_dir.join(target_path).to_s) || modified.include?(base_dir.join(target_path).to_s)
end
listener.start
wait_for_listener_start(listener)
begin
res = yield
raise "Timed out waiting for #{target_path}" unless path_created.wait(timeout)
ensure
listener.stop
end
res
end
# The listen gem creates an internal thread, @run_thread, which on most target systems
# is where the actually listening is done. However, on macOS, @run_thread creates a
# second thread, @worker_thread, which does the actual listening. It's possible that
# although the listener is started, the @worker_thread hasn't actually started yet.
# This leaves a window where the target_path we are waiting on can actually be created
# before the @worker_thread is started and we "miss" the creation of the target_path.
# This method ensures that we won't move on until that thread is ready, further ensuring
# we can't miss the creation of the target_path.
def wait_for_listener_start(listener)
if RbConfig::CONFIG['host_os'].include?("darwin")
listener_adapter = listener.instance_variable_get(:@backend).instance_variable_get(:@adapter)
until listener_adapter.instance_variable_get(:@worker_thread)&.alive?
sleep(0.01) # yield to other threads to allow them to start
end
end
end
def python_path
@python_path ||= [manageiq_venv_path, *ansible_python_paths].compact.join(File::PATH_SEPARATOR)
end
def manageiq_venv_path
Dir.glob("/var/lib/manageiq/venv/lib/python*/site-packages").first
end
def ansible_python_paths
ansible_python_paths_raw(ansible_python_version).chomp.split(":")
end
# NOTE: This method is ignored by brakeman in the config/brakeman.ignore
def ansible_python_paths_raw(version)
return "" if version.blank?
# This check allows us to ignore the brakeman warning about command line injection
raise "ansible python version is not a number: #{version}" unless version.match?(/^\d+\.\d+$/)
`python#{version} -c 'import site; print(":".join(site.getsitepackages()))'`.chomp
end
def ansible_python_version
ansible_python_version_raw.match(/python version = (\d+\.\d+)\./)&.captures&.first
end
def ansible_python_version_raw
`ansible --version 2>/dev/null`.chomp
end
end
end
end