Ansible Custom Action Plugins
- Introduction
- Motivation
- Where to place Action Plugins
- Basic layout
- Error handling
- Diagnostics and Verbose output
- Run-time environment
- Accessing task parameters
- More Task Context via task_vars
- Implementing check mode
- Implementing diff mode
- Template Rendering
- Calling Modules from Action Plugins
- Return values
- Conclusion
This is a continuation to my Ansible custom modules article.
Action plugins let you integrate local processing and local data with module functionality.
At the time of this writing, documentation I could find was Developing plugins - Action plugins which was somewhat out-of-date.
Introduction
Ansible action plugins are Python-based extensions that run on the control node, allowing you to intercept and modify the behavior of tasks before they're executed on remote hosts. Unlike regular modules, which run remotely, action plugins give you local control over task execution logic, argument manipulation, and result handling.
They’re especially useful when:
- You need to preprocess arguments or include local data before calling a module.
- You want to combine multiple modules into a single task.
- You’re working in environments with limited remote capabilities, or need local-only logic.
Each plugin is a class that inherits from ActionBase
, and typically overrides the
run()
method. Inside this method, you can use _execute_module()
to call the actual
module, or bypass it entirely for local-only tasks.
Motivation
While building an Ansible role for nftables, incorporating ideas from Frzk’s nftables role and kormat’s iptables-apply to handle recovery from new rulesets that would break or hang Ansible's control connection it became challeging to use YAML to manage control flow, error handling, and connection state.
By writing a custom action plugin, I could:
- Run logic locally on the control node
- Use Python to apply rules and handle failures gracefully
- Keep playbooks declarative, while isolating recovery logic in code
This gives a cleaner, more robust way to experiment with firewall changes without compromising automation reliability.
Where to place Action Plugins
Custom action plugins can be placed in several locations depending on your project structure and scope:
Role or Collection scope
- Inside a role:
Place the plugin inroles/<role_name>/action_plugins/
.
This makes it available only when the role is used. - Inside a collection:
Useplugins/action/
within your collection directory.
This is ideal for distributing reusable plugins.
User or System Scoped
-
User-level plugin directory:
~/.ansible/plugins/action/
This makes the plugin available across all playbooks for the current user. -
System-level plugin directory:
/usr/share/ansible/plugins/action/
Useful for global availability across users and projects.
Configuring Plugin Paths
To make Ansible recognize custom plugin locations, you can configure the plugin path:
- Via
ansible.cfg
Add or modify the following in youransible.cfg
file:[defaults] action_plugins = ./action_plugins:~/.ansible/plugins/action:/usr/share/ansible/plugins/action
- Paths are colon-separated.
- Relative paths (like
./action_plugins
) are resolved from the playbook directory.
- Via Environment Variable
Set theANSIBLE_ACTION_PLUGINS
environment variable:export ANSIBLE_ACTION_PLUGINS=./action_plugins:~/.ansible/plugins/action
This overrides the config file setting and is useful for temporary or dynamic setups.
You can inspect your current plugin paths using ansible-config dump | grep ACTION_PLUGIN_PATH
.
Basic layout
#!/usr/bin/python
# Make coding more python3-ish, this is required for contributions to Ansible
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
# Base class
from ansible.plugins.action import ActionBase
class ActionModule(ActionBase):
def run(self, tmp=None, task_vars=None):
if task_vars is None: task_vars = dict()
result = super().run(tmp, task_vars)
del tmp # tmp no longer has any effect
# Do something here...
return result
This is the simplest action plugin. It declares itself as a sub-class
of ActionBase
, and populates the task_vars
structure with values
and initializes result
with some defaults.
Error handling
Ansible leverages Python's exception system to handle errors cleanly. Within your custom action plugin, you can raise exceptions to signal failure conditions. Start by importing the relevant classes:
from ansible.errors import AnsibleError, AnsibleFileNotFound, AnsibleAction, AnsibleActionFail
You can also use Python’s built-in exceptions if preferred.
To raise an error:
raise AnsibleFileNotFound('thisfile.txt')
By default, Ansible displays a simplified error message. To see the full stack trace, run your playbook with increased verbosity:
ansible-playbook playbook.yml -vvv
This is especially useful when debugging plugin logic or tracing failures deep in the call stack.
Diagnostics and Verbose output
Custom action plugins often need to communicate status or debug information. Ansible
provides the Display
class for structured output:
from ansible.utils.display import Display
display = Display()
You can then emit messages based on verbosity level:
if display.verbosity > 1:
display.display('Verbose message')
Or use the built-in verbosity helpers:
display.v('Level 1 verbosity')
display.vv('Level 2 verbosity')
display.vvv('Level 3 verbosity')
Control verbosity from the command line using -v
, -vv
, or -vvv
. This lets users opt
into more detailed diagnostics without cluttering standard output.
Run-time environment
When writing custom action plugins, it is often useful to inspect the execution context; whether you're debugging behavior, adapting to playbook settings, or resolving file paths. Ansible exposes several runtime variables and constants that help you do just that.
Start by importing the constants module:
import ansible.constants as C
Here are some useful runtime indicators and paths:
Execution Context Flags
-
self._task.diff
Indicates if the task is running in diff mode (--diff
). -
self._task.check_mode
True if the task is running in check mode (--check
). -
self._task.no_log
True if the task or play has enabled theno_log
flag. -
self._task.role
A string with the current role, otherwise None.
Path Resolution
-
self._task.get_search_path()
Returns the search path used to resolve relative file names. -
C.DEFAULT_ROLES_PATH
Default path(s) where Ansible looks for roles. -
C.COLLECTIONS_PATHS
Path(s) used to locate collections. -
C.PLAYBOOK_DIR
Directory of the current playbook being executed.
Remote File Cleanup
-
C.DEFAULT_KEEP_REMOTE_FILES
Controls whether temporary files on remote hosts are cleaned up.
You can enable this by setting the environment variable:export ANSIBLE_KEEP_REMOTE_FILES=1
These variables are especially helpful when building plugins that need to behave differently based on execution mode or when resolving paths dynamically.
Accessing task parameters
Action plugins receive task parameters as a dictionary via self._task.args
. These are the
arguments defined in the playbook for the task invoking your plugin.
params = self._task.args
You can interact with this dictionary using standard Python methods like .get()
or direct
key access.
Note: Any parameters containing Jinja2 templates are rendered before the plugin is executed. This means you’ll receive fully evaluated values, not raw template strings.
This makes it easy to write plugins that behave predictably, without needing to manually resolve templating logic.
More Task Context via task_vars
In addition to parameters passed directly to your action plugin via self._task.args
,
Ansible provides a broader context through the task_vars
dictionary. This contains
all variables available to the task at runtime, including:
- Inventory & Host Variables
- Variables from
host_vars
andgroup_vars
inventory_hostname
,group_names
,ansible_play_hosts
- Variables from
- Facts
- Gathered facts like
ansible_distribution
,ansible_interfaces
,ansible_facts
- Custom facts from
setup
orset_fact
- Gathered facts like
- Plabook & Role Context
- Variables defined in
vars
,vars_files
, orvars_prompt
- Role defaults and vars
- Tags:
ansible_run_tags
,ansible_skip_tags
- Variables defined in
- Execution Flags
ansible_check_mode
: Whether the task is running in check modeansible_diff_mode
: Whether diff mode is enabledansible_dependent_role_names
: List of dependent roles
- User-defined Variables
- Variables passed via
--extra-vars
- Any custom values defined earlier in the play
- Variables passed via
From your run
method:
hostname = task_vars.get('inventory_hostname')
os_family = task_vars.get('ansible_os_family')
debug_flag = task_vars.get('my_custom_flag', False)
raw_value = task_vars.get('my_inventory_value')
Unlike self._task.args
, which contains pre-rendered values, entries in task_vars
may
still include raw Jinja2 expressions, especially when variables are defined in inventory
or playbooks using templating syntax.
To evaluate these expressions inside your plugin, use the templar:
my_value = self._templar.template(task_vars.get('my_inventory_value'))
os_family = self._templar.template(task_vars.get('ansible_os_family'))
This ensures your plugin works with fully resolved values, allowing it to behave consistently regardless of how variables were defined.
You can also use
template()
on dictionaries or lists to resolve nested structures.
Implementing check mode
Similar to custom modules, action plugins support check mode. When the user passes
the --check
flag, this will set the flag self._task.check_mode
to True. This
means the playbook is running in dry-run mode. See Ansible check mode.
When in check mode, the Action plugin must simulate changes without actually applying them to the system. Modules that support check mode, must report what would change or simply skip execution.
This is useful for:
- Validating playbooks before deployment
- Auditing potential changes
- Testing custom modules or logic safely
Implementing diff mode
Check mode is often paired with the --diff
command line argument. This
will set the self._task.diff
to True. When this is the case, Action
plugins must show the before-and-after changes, especially those that modify files
or configurations.
A quick example:
if self._task.diff and result['changed']:
if not 'diff' in result: result['diff'] = list()
result['diff'].append({
'before_header': 'hdr1 (original)',
'before': 'before\n',
'after_header': 'hdr1 (updated)',
'after': 'after\n',
'prepared': '>>> Preparation',
})
In this example, we are creating a list for the diff
key. You can return multiple
diff
sets. If you are only returning one (1) diff set, you can simply return it
directly (without wrapping it inside a list).
You can return at least before
,after
pair, or a prepared
key. Returning
all three is possible. before_header
and after_header
are optional.
Normally I would return before
and after
for when configuration files
are being changed. I would use the prepared
key for when we are running
commands on the system. For example, running mkfs
, lvcreate
, etc.
Template Rendering
When rendering templates from files within an action plugin, the following pattern ensures correctness and compatibility with Ansible’s templating engine:
-
Import the required dependency:
from ansible.template import Templar
-
Locate the template file:
template_path = self._task.args.get('template') srcfile = self._find_needle('templates', template_path)
This searches for the template in the configured
templates/
directories and resolves the full path. -
Adjust the loader's basedir to support
{% include %}
and relative paths:old_basedir = self._loader.get_basedir() self._loader.set_basedir(os.path.dirname(srcfile))
-
Render the template:
with open(srcfile, 'r') as fp: template = fp.read() tp = Templar(loader=self._loader, variables=task_vars) rendered = tp.template(template)
-
Restore the original basedir to avoid side effects:
self._loader.set_basedir(old_basedir)
Notes:
- Always reset the loader's basedir after rendering to prevent unexpected behavior in subsequent tasks.
- Using
Templar
directly gives you full control over variable resolution and template rendering, including support for Ansible-specific filters and syntax.
Calling Modules from Action Plugins
Action plugins often serve as orchestrators, combining logic, templating, and
conditional behavior before delegating actual work to modules. To invoke a module
from within an action plugin, use the _execute_module()
method, which handles
serialization, transport, and return parsing.
Example: Delegating to a Custom Module
mod_args = {
'dest': dest,
'files': '\n'.join(flist),
'ro_check': ro_check if ro_check else '',
}
mod_return = self._execute_module(
module_name='mab_prune',
module_args=mod_args,
task_vars=task_vars
)
Key Concepts
module_name
: This should match the name of the module file (without.py
) and be resolvable via the plugin loader. If you're calling a module from your own collection, use the fully qualified name (e.g.my_namespace.my_collection.mab_prune
).module_args
: A dictionary of arguments passed to the module. These should be flat and serializable. Avoid nested structures unless your module explicitly supports them.task_vars
: Pass the current task variables to ensure proper templating and context resolution. This includes facts, host vars, and role defaults.
Return Value
The result of _execute_module()
is a dictionary that mimics the return from a normal
module execution. It includes keys like:
changed
: Boolean indicating whether the module made changes.failed
: Boolean indicating failure.msg
: Optional message string.- Any custom return values defined by your module.
You can use this return to control flow, emit diagnostics, or raise errors:
if mod_return.get('failed'):
raise AnsibleActionFail("Module execution failed: %s" % mod_return.get('msg'))
Best Practices
- Avoid side effects before module execution: If your plugin modifies state before calling the module, ensure those changes are reversible in case the module fails.
- Respect check mode: If your plugin supports check mode, ensure the module you're
calling does too—or skip execution when
self._play_context.check_mode
isTrue
. - Use
Display.vvv()
for debugging: Log the module arguments and return values at verbosity level 3+ to aid troubleshooting.
Return values
Action plugins return a dictionary that mirrors the structure of module results. This ensures consistency across tasks and allows Ansible to interpret outcomes correctly.
Common Keys
changed
(bool
): Indicates whether the task made changes to the system. Set this toTrue
only when a state transition occurred.failed
(bool
): Signals task failure. IfTrue
, Ansible halts execution unlessignore_errors
is set.msg
(str
): A human-readable message describing the outcome or error.skipped
(bool
): Marks the task as intentionally skipped, often used with conditional logic.invocation
(dict
): Automatically populated metadata about how the plugin was called. Useself._get_invocation(task_vars)
to include it.
Custom Return Values
You can include additional keys specific to your plugin or module logic:
return {
'changed': True,
'pruned_files': pruned,
'skipped_files': skipped,
'msg': "Pruned %d files" % len(pruned),
'invocation': self._get_invocation(task_vars),
}
For more common return values see Common Return values.
Conclusion
Action plugins offer deep control over task execution, making them ideal for implementing custom logic, enforcing policy, and handling edge cases like connection loss or remote cleanup. But with that control comes complexity; especially around templating, context flags, and runtime behavior.
This article outlined practical patterns for building reliable, maintainable plugins: from placement and layout to diagnostics, error handling, and module delegation. The goal is not just flexibility, but predictability -- plugins that behave consistently across environments, expose their decisions clearly, and fail gracefully when needed.
In automation, predictability builds trust. And trust is what lets teams move fast without breaking things.