Ansible ~~~~~~~ Ansible Action Plugin micro framework ===================================== Requires ``ansible``, to build `custom action plugins `_ Action plugins are generally preferable to Module plugins: - they execute on the Ansible controller - which is faster - they can still call any module on the target host - they can stream output (logs) in real-time Useful on its own, it also integrates very well with the rest of our stuff: - :doc:`display` - :doc:`log` - :doc:`client` - :doc:`lock` Example Plugin -------------- This uses the example cli2 Client that we developed in :ref:`the cli2.client example`: .. literalinclude:: ../tests/yourlabs/test/plugins/action/restful_api.py Async ----- With :py:class:`~cli2.ansible.action.ActionBase`, we don't define run, we define :py:meth:`~cli2.ansible.action.ActionBase.run_async` .. code-block:: python import cli2 from cli2 import ansible class ActionModule(ansible.ActionBase): async def run_async(self): self.tmp # is the usual tmp arg self.task_vars # is the usual task_vars arg cli2.log.debug('Getting something') self.result['failed'] = True cli2.log.info('Got something', json=something) And then you get absolutely beautiful logging: - ``json`` logger key is configured to render as colored yaml - with ``-v``: log level will be set to ``INFO``, which you should you use to indicate that something **has been done** - with ``-vv``: log level will be set to ``DEBUG``, which you should you use to indicate that something **is going to be attempted** Without ``-v``: .. image:: ansible_log_nov.png With ``-v``: .. image:: ansible_log_v.png With ``-vv``: .. image:: ansible_log_vv.png Option ------ With :py:class:`~cli2.ansible.action.Option`, we can declare task options instead of fiddling with task_vars: .. code-block:: python class ActionModule(ansible.ActionBase): # an option with a default name = ansible.Option(arg='name', default='test') # an option without default: if not specified, the module will fail and # request that the user configures a value for this option nodefault = ansible.Option('nodefault') # option that takes value from a fact global_option = ansible.Option(fact='your_fact') # an option that takes value from a task arg if available, otherwise # from a fact, otherwise default region = ansible.Option(arg='region', fact='region', default='EU') async def run_async(self): self.result['name'] = self.name self.result['region'] = self.region # ... An option can specify an argument name, and/or a global fact name, and a default. Client ------ You need to return a :py:class:`~cli2.client.Client` instance in the :py:meth:`~cli2.ansible.action.ActionBase.client_factory` method to have a ``self.client`` attribute: .. code-block:: python class ActionModule(ansible.ActionBase): name = ansible.Option('name', default='test-name') value = ansible.Option('value', default='test-value') async def client_factory(self): return YourClient() async def run_async(self): obj = await self.client.YourObject.find(name=self.name).first() # .... And then you get absolutely beautiful logging: - with ``-v``: log level will be set to ``INFO``, which means you will see responses. - with ``-vv``: log level will be set to ``DEBUG``, which means you will see requests too. There is no way to set :envvar:`DEBUG` from ansible, as we never want masked secrets to output in an Ansible Tower job. But you can still export ``DEBUG=1`` prior to executing Ansible manually, which will dump all pagination requests/responses and secrets. Without ``-v``: .. image:: ansible_noverbose.png With ``-v``: .. image:: ansible_v.png With ``-vv``: .. image:: ansible_vv.png Diff ---- We're going to be changing stuff, and Ansible doesn't interpret before/after result keys which means it won't dump a diff even with ``--diff``. Instead of calling :py:func:`~cli2.display.diff_data` manually, you can call :py:meth:`~cli2.ansible.action.ActionBase.before_set` and :py:meth:`~cli2.ansible.action.ActionBase.after_set`, then a diff will be displayed automatically. .. code-block:: python class ActionModule(ansible.ActionBase): name = ansible.Option('name', default='test-name') value = ansible.Option('value', default='test-value') async def client_factory(self): return YourClient() async def run_async(self): obj = await self.client.Object.find(name=self.name).first() if self.verbosity: # don't display diff if not -v self.before_set(obj.data) obj.value = self.value if obj.changed_fields: response = await obj.save() self.result['changed'] = True if self.verbosity: self.after_set(obj.data) # we can also get masked data key, value = self.client.response_log_data(response) self.result[key] = value Also, I note that I always forget to pass ``--diff`` anyway, so do my users, I'm assuming the user is trying to understand what's going on as soon as they pass a single ``-v``, so, this example will only check if any verbosity is activated at all to display the diff. If you really want the diff to display only with ``--diff``, then wrap your before_set/after_set in ``if self.task_vars['ansible_diff_mode']`` instead of ``if self.verbosity``. Testing ------- You can run the module in mocked mode in tests with the :py:meth:`~cli2.ansible.action.ActionModule.run_test_async` method: .. code-block:: python @pytest.mark.asyncio async def test_module(): module = await your.ActionModule.run_test_async( args=dict( name='test', capacity='5', price='3', ) ) assert module.result['changed'] For HTTP response mocking, you should use httpx_mock from pytest-httpx, as seen in the example below: .. literalinclude:: ../tests/test_restful.py Variables reader ================ .. automodule:: cli2.ansible.variables :members: Playbook generator ================== You can also create playbooks on the fly and run them in a subprocess that calls ansible-playbook in localhost, thanks to the :py:class:`~cli2.pytest_ansible.Playbook` fixture: .. code-block:: python def test_playbook_exec(playbook): playbook.task_add('debug', args=dict(msg='hello')) result = playbook() assert result['changed'] == 0 assert result['ok'] == 2 The previous, mocking solution, is always preferable. But if you also want functional tests, then this works great. While the internal Python API of Ansible would also work, this uses Ansible public API which is less subject to change. Documenting =========== That's a bit trickier, you have to put a module plugin with a name matching your action plugin and set the documentation in YAML strings in there. Once your documentation outputs properly with ``ansible-doc`` command, you can have it in your Sphinx documentation with various plugins that you'll find easily on internet, except probably for mine: `ansible-sphinx `_. API === .. automodule:: cli2.ansible.action :members: .. automodule:: cli2.ansible.playbook :members: