This document has my study notes of Ansible and also serves as a quick guide for me to recall how to use Ansible for various purposes. The original README is here.
In order to help understand the code, I added comments or debugging log messages which all have the tag [ywen]
.
I created a branch ywen/v2.9.12
to study the code of the version 2.9.12
. As of 2021-12-14, I haven't studied the code of any other versions.
The latest version (i.e., the devel
version) of the developer guide is here: Developer Guide. Note that Ansible may re-organize their documentation site so the links may become inaccessible. Should this happen, search the key word "Ansible Developer Guide".
This section uses the following references:
The development environment preparation is part of a larger scenario: Developing Ansible modules [2.1].
To prepare the development environment, refer to [2.1.1]. The main steps are as follows (assuming Ubuntu):
-
1). Install prerequisites:
- The Debian packages:
- build-essential
- libssl-dev
- libffi-dev
- python-dev
- python3-dev
- python3-venv
- The Debian packages:
-
2). Clone the Ansible repository:
$ git clone https://github.com/ansible/ansible.git
-
3). Change directory into the repository root dir:
$ cd ansible
-
4). Create a virtual environment:
$ python3 -m venv venv
(or for Python 2$ virtualenv venv
. Note, this requires you to install thevirtualenv
package:$ pip install virtualenv
)
-
5). Activate the virtual environment:
$ . venv/bin/activate
-
6). Install development requirements:
$ pip install -r requirements.txt
- Make sure to upgrade
pip
:pip install --upgrade pip
because Ubuntu 18.04 providespip 9.0.1
which is too old. - May need to install
setuptools_rust
using the latest version ofpip
:pip install setuptools_rust
.
- Make sure to upgrade
-
7). Run the environment setup script for each new development shell process:
$ . hacking/env-setup
After the initial setup above, every time you are ready to start developing Ansible you should be able to just run the following from the root of the Ansible repo: $ . venv/bin/activate && . hacking/env-setup
.
The document [2.1] mentions three types of modules:
Type | Filename | Description | Template |
---|---|---|---|
info | *_info.py |
Gathers information on other objects or files. | [2.1.2] |
facts | *_facts.py |
Gather information about the target machines. | [2.1.2] |
general-purpose | *.py |
For other purposes other than information or facts gathering. | [2.1.3] |
[2.1] also talks about how to test the newly created module, such as sanity test and unit test.
As a note says:
Ansible uses
pytest
for unit testing.
Usually, one cannot test the run_module()
function directly because it requires two things:
stdin
asAnsibleModule
reads its input arguments from the standard input.exit_json()
andfail_json()
callsys.exit()
which will cause the test program to exit.
Therefore, usually one can only test the functions that the Ansible module calls. But the patch_ansible_module()
function makes it possible to test the Ansible module directly:
@pytest.fixture
def patch_ansible_module(request, mocker):
if isinstance(request.param, string_types):
args = request.param
elif isinstance(request.param, MutableMapping):
if 'ANSIBLE_MODULE_ARGS' not in request.param:
request.param = {'ANSIBLE_MODULE_ARGS': request.param}
if '_ansible_remote_tmp' not in request.param['ANSIBLE_MODULE_ARGS']:
request.param['ANSIBLE_MODULE_ARGS']['_ansible_remote_tmp'] = '/tmp'
if '_ansible_keep_remote_files' not in request.param['ANSIBLE_MODULE_ARGS']:
request.param['ANSIBLE_MODULE_ARGS']['_ansible_keep_remote_files'] = False
args = json.dumps(request.param)
else:
raise Exception('Malformed data to the patch_ansible_module pytest fixture')
mocker.patch('ansible.module_utils.basic._ANSIBLE_ARGS', to_bytes(args))
Currently (as of 2022-01-09), the only tests that use patch_ansible_module()
is test_pip.py
.
Use the module lib/ansible/utils/display.py
. Search the code from ansible.utils.display import Display
or something similar to find the examples in the codebase.
The "debug output" can refer to two things in Ansible, so be specific when talking about "debug output".
The first one is the log messages that are printed out by Display.debug()
method:
class Display(metaclass=Singleton):
# ...
def debug(self, msg, host=None):
if C.DEFAULT_DEBUG:
if host is None:
self.display("%6d %0.5f: %s" % (os.getpid(), time.time(), msg), color=C.COLOR_DEBUG)
else:
self.display("%6d %0.5f [%s]: %s" % (os.getpid(), time.time(), host, msg), color=C.COLOR_DEBUG)
These debugging log messages can be toggled by the environment variable ANSIBLE_DEBUG
and the color can be configured by ANSIBLE_COLOR_DEBUG
. For example:
ywen@ywen-Precision-7510:~$ export ANSIBLE_COLOR_DEBUG="bright yellow"
ywen@ywen-Precision-7510:~$ export ANSIBLE_DEBUG=1
ywen@ywen-Precision-7510:~$ ansible -m ping localhost
The second one is the output of the ansible.builtin.debug
module.
ansible_facts
has facts about the remote system:
- Run
ansible <hostname> -m ansible.builtin.setup
to print the raw information gathered for the remote system.- NOTE: If nothing is printed, try in a different folder which doesn't have
ansible.cfg
. I haven't looked into why a localansible.cfg
may cause the modulesetup
to print nothing but I ran into this today (2021-08-12). - The raw information can be accessed directly, e.g.,
"{{ansible_system}}"
. - It can be accessed via the variable
ansible_facts
, too:{{ansible_facts.system}}
which is equivalent to"{{ansible_system}}"
.
- NOTE: If nothing is printed, try in a different folder which doesn't have
- Run
ansible <hostname> -m ansible.builtin.setup -a "filter=ansible_local"
to print just the information of the specified part. - Click
ansible_facts_raw.json
to see a sample (from runningansible.builtin.setup
).
The Ansible document "Special Variables" doesn't list the details of some of the special variables. Here are some concrete examples for reference:
hostvars
:- A dictionary/map with all the hosts in inventory and variables assigned to them.
hostvars.json
groups
:- A dictionary/map with all the groups in inventory and each group has the list of hosts that belong to it.
groups.json
group_names
:- List of groups the current host is part of.
group_names.json
inventory_hostname
:- The inventory name for the ‘current’ host being iterated over in the play.
inventory_hostname.json
This section is based on the version 2.9.12
. Check out the branch ywen/v2.9.12
.
The bin/ansible
is a symbolic link pointing at lib/ansible/cli/scripts/ansible_cli_stub.py
which is the entry point of all the CLI execution. For example, running ansible-playbook
will eventually run into the main module of ansible_cli_stub.py
.
lib/ansible/cli
has multiple modules:
adhoc.py
: For arbitrary Ansible module execution (e.g.,ansible -m
).config.py
: For the CLIansible-config
.console.py
: For the CLIansible-console
.doc.py
: For the CLIansible-doc
.galaxy.py
: For the CLIansible-galaxy
.inventory.py
: For the CLIansible-inventory
.playbook.py
: For the CLIansible-playbook
.pull.py
: For the CLIansible-pull
.vault.py
: For the CLIansible-vault
.
The class PlaybookCLI
(lib/ansible/cli/playbook.py
) is the CLI for running ansible-playbook
. Look at its run
method which uses the class PlaybookExecutor
(lib/ansible/executor/playbook_executor.py
) to run the playbook. Look at its run
method.
PlaybookExecutor
uses a TaskQueueManager
(lib/ansible/executor/task_queue_manager.py
) to run the tasks. Look at its run
method.
Note that TaskQueueManager
actually uses the strategy to run the tasks:
# load the specified strategy (or the default linear one)
strategy = strategy_loader.get(new_play.strategy, self)
if strategy is None:
raise AnsibleError("Invalid play strategy specified: %s" % new_play.strategy, obj=play._ds)
# ...
# and run the play using the strategy and cleanup on way out
display.debug("[ywen] Run the play using the strategy {s}".format(s=strategy))
play_return = strategy.run(iterator, play_context)
When we debug a playbook, sometimes we want to figure out the actual path of the role in the playbook. As of v2.9.12
, there doesn't seem to be a CLI option of ansible
or ansible-playbook
to show the role paths. But there are two other methods to do it.
The first method uses the debug output: Run export ANSIBLE_DEBUG=1
to enable debugging output. Then look for the debug log messages "Loading data from":
15267 1639582174.41790: Loading data from /home/ywen/wc/stable/minevisionsystems/hecla/ansible/roles/spinnaker/defaults/main.yml
15267 1639582174.41857: Loading data from /home/ywen/wc/stable/minevisionsystems/hecla/ansible/roles/spinnaker/tasks/main.yml
The second method is to hack the code (assuming v2.9.27
):
sudo vim /usr/lib/python2.7/dist-packages/ansible/playbook/role/definition.py
- Find the following block (which should be line 90 ~ 94):
# first we pull the role name out of the data structure,
# and then use that to determine the role path (which may
# result in a new role name, if it was a file path)
role_name = self._load_role_name(ds)
(role_name, role_path) = self._load_role_path(role_name)
- Add a line of
display.v(...
right below the_load_role_path
line:
(role_name, role_path) = self._load_role_path(role_name)
display.v("Found role '{n}' at '{p}'".format(n=role_name, p=role_path))
- Running
ansible-playbook -v
(or any verbosity higher than-v
) will print the used role path:
TASK [Spinnaker SDK requires preseeding debconf(1) to avoid interactive prompts while installing.] *********************************************************************************************************
Found role 'spinnaker' at '/home/ywen/wc/stable/minevisionsystems/hecla/ansible/roles/spinnaker'
In general, there are (at least) two ways of setting check_mode
:
- Use the option
--check
on the command line (e.g.,ansible-playbook --check
oransible --check
). - Set
_ansible_check_mode
inANSIBLE_MODULE_ARGS
.
But the first method will (probably) eventually use the second method, because an Ansible module is not executed directly, but firstly packed into an AnsibleZ
tarball (the input arguments included) and then sent to the target machine to run.
lib/ansible/utils/vars.py
has the function load_options_vars()
:
lib/ansible/vars/manager.py
has the class VariableManager
. VariableManager.__init__
calls load_options_vars()
to load option variables in self._options_vars = load_options_vars(version_info)
:
def load_options_vars(version):
if version is None:
version = 'Unknown'
options_vars = {'ansible_version': version}
attrs = {'check': 'check_mode',
'diff': 'diff_mode',
'forks': 'forks',
'inventory': 'inventory_sources',
'skip_tags': 'skip_tags',
'subset': 'limit',
'tags': 'run_tags',
'verbosity': 'verbosity'}
for attr, alias in attrs.items():
opt = context.CLIARGS.get(attr)
if opt is not None:
options_vars['ansible_%s' % alias] = opt
return options_vars
ansible_check_mode
is returned by VariableManager._get_magic_variables()
in the "Set options vars" part (near the end of the function):
# Set options vars
for option, option_value in iteritems(self._options_vars):
variables[option] = option_value
In VariableManager.get_vars()
, the magic variables are combined into all_vars
: all_vars = combine_vars(all_vars, magic_variables)
.
As section "4. Modules and Files" says, TaskQueueManager
is eventually used to run the Ansible tasks.
TaskQueueManager.run()
loads the strategy and call the strategy's run()
method: play_return = strategy.run(iterator, play_context)
. By default, the strategy is the linear
strategy.
In linear.StrategyModule.run()
, the variables are loaded into task_vars
:
task_vars = self._variable_manager.get_vars(
play=iterator._play, host=host, task=task,
_hosts=self._hosts_cache, _hosts_all=self._hosts_cache_all
)
Then task_vars
are passed into self._queue_task(host, task, task_vars, play_context)
.
In StrategyBase._queue_task()
, task_vars
are passed into WorkerProcess
: worker_prc = WorkerProcess(self._final_q, task_vars, host, task, play_context, self._loader, self._variable_manager, plugin_loader)
.
In WorkerProcess.__init__()
, task_vars
are recorded in self._task_vars
. In WorkerProcess._run()
, self._task_vars
are passed into TaskExecutor
as its job_vars
:
executor_result = TaskExecutor(
self._host,
self._task,
self._task_vars,
self._play_context,
self._new_stdin,
self._loader,
self._shared_loader_obj,
self._final_q
).run()
TaskExecutor.run()
calls TaskExecutor._execute()
without setting variables
(L147): res = self._execute()
.
So TaskExecutor._execute()
uses self._job_vars
as variables
:
if variables is None:
variables = self._job_vars
TaskExecutor._execute()
then runs self._play_context = self._play_context.set_task_and_variable_override(task=self._task, variables=variables, templar=templar)
to override variables if needed.
Finally, TaskExecutor._execute()
runs the handler (which is an action plugin) to run the module: result = self._handler.run(task_vars=variables)
. self._handler = self._get_action_handler(connection=self._connection, templar=templar)
which in the default case the normal
handler that's defined in lib/ansible/plugins/action/normal.py
.
The normal
action plugin calls ActionBase._execute_module()
to run the module. ActionBase._execute_module()
runs self._update_module_args(module_name, module_args, task_vars)
to update the module's arguments.
ActionBase._update_module_args()
:
# set check mode in the module arguments, if required
if self._play_context.check_mode:
if not self._supports_check_mode:
raise AnsibleError("check mode is not supported for this operation")
module_args['_ansible_check_mode'] = True
else:
module_args['_ansible_check_mode'] = False
OK, so now module_args
has _ansible_check_mode
set.
But note that the Ansible module is packed up into an AnsibleZ
tarball together with the arguments and sent to the target machine to run. On the target machine, the module's run_module()
function is called.
Typically, inside run_module()
, AnsibleModule
is instantiated. AnsibleModule.__init__()
calls AnsibleModule._check_arguments()
which does the following:
for k in PASS_VARS:
# handle setting internal properties from internal ansible vars
param_key = '_ansible_%s' % k
if param_key in param:
if k in PASS_BOOLS:
setattr(self, PASS_VARS[k][0], self.boolean(param[param_key]))
else:
setattr(self, PASS_VARS[k][0], param[param_key])
# clean up internal top level params:
if param_key in self.params:
del self.params[param_key]
else:
# use defaults if not already set
if not hasattr(self, PASS_VARS[k][0]):
setattr(self, PASS_VARS[k][0], PASS_VARS[k][1])
where PASS_VARS
contains check_mode
:
PASS_VARS = {
'check_mode': ('check_mode', False),
# ...
}
For AnsibleModule._check_arguments(self, check_invalid_arguments, spec=None, param=None, legal_inputs=None)
, when param
is None
:
if param is None:
param = self.params
self.params
is set in def _load_params(self)
:
def _load_params(self):
''' read the input and set the params attribute.
This method is for backwards compatibility. The guts of the function
were moved out in 2.1 so that custom modules could read the parameters.
'''
# debug overrides to read args from file or cmdline
self.params = _load_params()
_load_params()
eventually only returns params['ANSIBLE_MODULE_ARGS']
. So if I want to override any ansible_*
variable, I can include it in params['ANSIBLE_MODULE_ARGS']
as _ansible_*
(note there must be the leading underscore _
). For example:
{
"ANSIBLE_MODULE_ARGS": {
"_ansible_check_mode": True,
}
}
See demo/ansible/roles/unittest-module/library/test_my_test.py
for an example.