Forward
About a year ago serversforhackers posted a great article on how to run Ansible programamatically Since then Ansible has had a major release which introduced changes within the Python API.
Simulating The CLI
Not that long ago Jason DeTiberus and I were talking about how to use Ansible from within other Python packages. One of the things he said was that it should be possible to reuse the command line code instead of the internal API if you hook into the right place. I finally had some time to take a look and it seems he’s right!
If you took a look at the 2.0 API you’ll see there is a lot more power handed over to you as the developer but with that comes a lot of code. Code that for many will be nearly copy/paste style code directly from command-line interface code. So when there is not a need for the extra power why not just reuse code that already exists?
import os # Used for expanding paths from ansible.cli.playbook import PlaybookCLI from ansible.errors import AnsibleOptionsError, AnsibleParserError def execute_playbook(playbook, hosts, args=[]): """ :param playbook: Full path to the playbook to execute. :type playbook: str :param hosts: A host or hosts to target the playbook against. :type hosts: str, list, or tuple :param args: Other arguments to pass to the run. :type args: list :returns: The TaskQueueHandler for the run. :rtype: ansible.executor.task_queue_manager.TaskQueueManager. """ # Set hosts args up right for the ansible parser. It likes to have trailing ,'s if isinstance(hosts, basestring): hosts = hosts + ',' elif hasattr(hosts, '__iter__'): hosts = ','.join(hosts) + ',' else: raise AnsibleParserError('Can not parse hosts of type {}'.format( type(hosts))) # Create the cli object cli_args = ['playbook'] + args + ['-i', hosts, os.path.realpath(playbook)] print('Executing: {}'.format(' '.join(cli_args))) cli = PlaybookCLI(cli_args) # Parse args and run it try: cli.parse() # Return the result: # 0: Success # 1: "Error" # 2: Host failed # 3: Unreachable # 4: Parser Error # 5: Options error return cli.run() except (AnsibleParserError, AnsibleOptionsError) as error: print('{}: {}'.format(type(error), error)) raise error
Breaking It Down
The function starts off with some hosts parsing. This is not really needed but it does make the function easier to work with. On the command line Ansible likes to have a comma at the end of hosts passed in. This chunk of code makes sure that if a list or string is given for a host that the resulting host string is properly formatted.
# Set hosts args up right for the ansible parser. It likes to have trailing ,'s if isinstance(hosts, basestring): hosts = hosts + ',' elif hasattr(hosts, '__iter__'): hosts = ','.join(hosts) + ',' else: raise AnsibleParserError('Can not parse hosts of type {}'.format(type(hosts)))
The Real Code
This chunk of code is what is actually calling Ansible. It creates the command line argument list, creates a PlaybookCLI
instance, has it parsed, and then executes the playbook.
# Create the cli object cli_args = ['playbook'] + args + ['-i', hosts, os.path.realpath(playbook)] print('Executing: {}'.format(' '.join(cli_args))) cli = PlaybookCLI(cli_args) # Parse args and run it try: cli.parse() # Return the result: # 0: Success # 1: "Error" # 2: Host failed # 3: Unreachable # 4: Parser Error # 5: Options error return cli.run() except (AnsibleParserError, AnsibleOptionsError) as error: print('{}: {}'.format(type(error), error)) raise error
Using The Function
# Execute /tmp/test.yaml with 2 hosts result = execute_playbook('/tmp/test.yaml', ['192.168.152.100', '192.168.152.101']) # Execute /tmp/test.yaml with 1 host and add the -v flag result = execute_playbook('/tmp/test.yaml', '192.168.152.101', ['-v'])
Intercepting The Output
One drawback of using the command-line interface code directly is that the output is expected to go to the user in the standard way. That is to say, it’s sent to the screen and colorized. This will probably be fine for some, but others may want to grab the output and use it in some form. While it is possible to change output through the configuration options it is also possible to monkey patch display
and intercept the output for your own use cases. As an example, here is a Display
class which forwards all output that is not meant for the screen only to our logging.info
method.
# MONKEY PATCH to catch output. This must happen at the start of the code! import logging from ansible.utils.display import Display # Set up our logging logger = logging.getLogger('transport') logger.setLevel(logging.INFO) handler = logging.StreamHandler() handler.formatter = logging.Formatter('%(name)s - %(message)s') logger.addHandler(handler) class LogForward(Display): """ Quick hack of a log forwarder """ def display(self, msg, screen_only=None, *args, **kwargs): """ Pass display data to the logger. :param msg: The message to log. :type msg: str :param args: All other non-keyword arguments. :type args: list :param kwargs: All other keyword arguments. :type kwargs: dict """ # Ignore if it is screen only output if screen_only: return logging.getLogger('transport').info(msg) # Forward it all to display info = display warning = display error = display # Ignore debug debug = lambda s, *a, **k: True # By simply setting display Ansible will slurp it in as the display instance display = LogForward() # END MONKEY PATCH. Add code after this line.
Putting It All Together
If you want to use it all together it should look like this:
# MONKEY PATCH to catch output. This must happen at the start of the code! import logging from ansible.utils.display import Display # Set up our logging logger = logging.getLogger('transport') logger.setLevel(logging.INFO) handler = logging.StreamHandler() handler.formatter = logging.Formatter('%(name)s - %(message)s') logger.addHandler(handler) class LogForward(Display): """ Quick hack of a log forwarder """ def display(self, msg, screen_only=None, *args, **kwargs): """ Pass display data to the logger. :param msg: The message to log. :type msg: str :param args: All other non-keyword arguments. :type args: list :param kwargs: All other keyword arguments. :type kwargs: dict """ # Ignore if it is screen only output if screen_only: return logging.getLogger('transport').info(msg) # Forward it all to display info = display warning = display error = display # Ignore debug debug = lambda s, *a, **k: True # By simply setting display Ansible will slurp it in as the display instance display = LogForward() # END MONKEY PATCH. Add code after this line. import os # Used for expanding paths from ansible.cli.playbook import PlaybookCLI from ansible.errors import AnsibleOptionsError, AnsibleParserError def execute_playbook(playbook, hosts, args=[]): """ :param playbook: Full path to the playbook to execute. :type playbook: str :param hosts: A host or hosts to target the playbook against. :type hosts: str, list, or tuple :param args: Other arguments to pass to the run. :type args: list :returns: The TaskQueueHandler for the run. :rtype: ansible.executor.task_queue_manager.TaskQueueManager. """ # Set hosts args up right for the ansible parser. It likes to have trailing ,'s if isinstance(hosts, basestring): hosts = hosts + ',' elif hasattr(hosts, '__iter__'): hosts = ','.join(hosts) + ',' else: raise AnsibleParserError('Can not parse hosts of type {}'.format( type(hosts))) # Create the cli object cli_args = ['playbook'] + args + ['-i', hosts, os.path.realpath(playbook)] logger.info('Executing: {}'.format(' '.join(cli_args))) cli = PlaybookCLI(cli_args) # Parse args and run it try: cli.parse() # Return the result: # 0: Success # 1: "Error" # 2: Host failed # 3: Unreachable # 4: Parser Error # 5: Options error return cli.run() except (AnsibleParserError, AnsibleOptionsError) as error: logger.error('{}: {}'.format(type(error), error)) raise error
Pros and Cons
Of course nothing is without drawbacks. Here are some negatives with this method:
- No direct access to “TaskQueueManager“
- If the CLI changes the code must change
- Monkey patching …. ewww
But the positives seem to be worth it so far:
- You don’t have to deal with “TaskQueueManager“ and all of the construction code
- The CLI doesn’t seem to change often
- The same commands one would run on the CLI can easily be extrapolated and even run manually