Action plugins act in conjunction with modules to execute the actions required by playbook tasks. They usually execute automatically in the background doing prerequisite work before modules execute.

– https://docs.ansible.com/ansible/latest/plugins/action.html

I found this definition vague, and if you’re trying to figure out if action plugins would be a useful tool for your ansible project it doesn’t really move the needle one way or another.

Here’s how I would explain them:

Action plugins give you an alternative to running modules directly from your playbook tasks. Action plugins hook into the internal Ansible task execution lifecycle and let you run arbitrary python code on your control node with access to all the variables in scope at a given task and the full internal Ansible API.

The classic example that you’ll find on the internet and in use with native Ansible modules is the templating of a file. The file always exists on the control node so by using an action plugin you can skip running a task delegated to localhost.

So are action plugins the same as custom modules that you run with delegate_to: localhost?

Not quite, the main difference is that action plugins get access to the full set of task variables in scope at the time they are invoked. A custom module only receives the parameters it declares and that are passed in by the task. A custom module is also expected to be able to run remotely.

Action plugins also have access to the internal Ansible API during execution which you can use to call other modules. This is how the template action plugin works - it runs the templating on the control server and then invokes the copy module to deploy the file.

Furthermore on the delegation to localhost - I’d argue that the pattern of using delegate_to: localhost as way to access resources on the control server is an anti-pattern. The question I ask myself as to whether I should write an action plugin or a custom module is, “Would it make sense to ever run this logic on a remote node?” - if the answer is no, it should probably be an action plugin.

Another interesting property of action plugins is that if you name the plugin file with the same name as another module, your action plugin code will be invoked instead of the module. This means you can “wrap” existing modules with your own logic which could have all kinds of interesting uses. You can also give your action plugin a unique name and invoke it the same way you would a regular module with a task.

Here’s an example action plugin that I’ve written - note how I support check mode here in my plugin by always running the templating step by creating a new play context:

import os
import tempfile

from ansible.plugins.action import ActionBase


class ActionModule(ActionBase):

  def run(self, tmp=None, task_vars=None):
    result = super(ActionModule, self).run(tmp, task_vars)

    with tempfile.NamedTemporaryFile() as tmp_file:
      new_task = self._task.copy()
      new_task.args = dict(
        src=self._task.args['datafile'],
        dest=tmp_file.name
      )
      # we always want to run the template locally even in check mode so we can generate diffs
      new_task_context = self._play_context.copy()
      new_task_context.check_mode = False
      template_action = self._shared_loader_obj.action_loader.get('template',
                                                              task=new_task,
                                                              connection=self._connection,
                                                              play_context=new_task_context,
                                                              loader=self._loader,
                                                              templar=self._templar,
                                                              shared_loader_obj=self._shared_loader_obj)

      result.update(template_action.run(task_vars=task_vars))
      if 'failed' in result and result['failed']:
        return result

      new_args = self._task.args.copy()
      new_args['datafile'] = tmp_file.name
      result = self._execute_module(module_name='my_custom_module_that_accepts_xml', module_args=new_args, task_vars=task_vars)

    return result

If this action plugin were saved in action_plugins/my_custom_action_plugin_that_accepts_templates.py it could be invoked in a playbook like this:

- my_custom_action_plugin_that_accepts_templates:
    datafile: 'templates/my_template.j2'
  delegate_to: localhost

And you could continue to invoke your custom module on localhost as follows provided you only feed it XML files (assuming your module doesn’t handle templating itself):

- my_custom_module_that_accepts_xml:
    datafile: 'files/my_file.xml'
  delegate_to: localhost