Ansible Action Plugins — Custom Task Execution Logic
Introduction
Action plugins run on the controller node and execute before the module runs on the target host. They control how tasks are dispatched — handling file transfers, template rendering, connection management, and module invocation. The built-in copy, template, fetch, and script modules are all implemented as action plugins.
When you need behavior that happens on the controller (not the target), or need to modify how a module is called, action plugins are the right tool.
How Action Plugins Work
Task Execution Flow:
1. Ansible reads task from playbook
2. Looks for action plugin matching module name
3. If found → action plugin runs on CONTROLLER
└─ Action plugin may call module on TARGET
4. If not found → default action plugin runs module on TARGET
Plugin Directory Structure
my_collection/
├── plugins/
│ └── action/
│ ├── __init__.py
│ └── my_action.py
└── plugins/
└── modules/
└── my_action.py # Companion module (optional)
Or in a role:
my_role/
├── action_plugins/
│ └── my_action.py
└── library/
└── my_action.py
Basic Action Plugin
# plugins/action/validate_and_deploy.py
from ansible.plugins.action import ActionBase
from ansible.errors import AnsibleError
class ActionModule(ActionBase):
"""Action plugin that validates config before deploying."""
def run(self, tmp=None, task_vars=None):
# Always call super first
result = super().run(tmp, task_vars)
task_vars = task_vars or {}
# Get task arguments
src = self._task.args.get('src')
dest = self._task.args.get('dest')
validate_cmd = self._task.args.get('validate')
if not src or not dest:
return {'failed': True, 'msg': 'src and dest are required'}
# Step 1: Template the source file on controller
try:
source_full = self._find_needle('files', src)
with open(source_full, 'r') as f:
content = f.read()
except AnsibleError as e:
return {'failed': True, 'msg': str(e)}
# Step 2: Transfer file to target
tmp_dest = self._connection._shell.join_path(
self._make_tmp_path(), 'validate_file'
)
self._transfer_data(tmp_dest, content)
# Step 3: Validate on target (if validate command given)
if validate_cmd:
validate_full = validate_cmd.replace('%s', tmp_dest)
validate_result = self._low_level_execute_command(validate_full)
if validate_result['rc'] != 0:
return {
'failed': True,
'msg': f'Validation failed: {validate_result["stderr"]}',
}
# Step 4: Move to final destination using copy module
module_args = {
'src': tmp_dest,
'dest': dest,
'remote_src': True,
}
result = self._execute_module(
module_name='ansible.builtin.copy',
module_args=module_args,
task_vars=task_vars,
)
return result
Using Built-In Methods
Key ActionBase Methods
class ActionModule(ActionBase):
def run(self, tmp=None, task_vars=None):
result = super().run(tmp, task_vars)
# Execute a module on the remote host
result = self._execute_module(
module_name='ansible.builtin.command',
module_args={'_raw_params': 'hostname'},
task_vars=task_vars,
)
# Run a raw command on the remote
cmd_result = self._low_level_execute_command('uname -a')
# Transfer a file to the remote
self._transfer_file('/local/path', '/remote/path')
# Transfer string data to remote
self._transfer_data('/remote/path', 'file content here')
# Create temporary directory on remote
tmp_path = self._make_tmp_path()
# Template a string with Jinja2
templated = self._templar.template('{{ ansible_hostname }}')
# Find a file in role's files/ directory
path = self._find_needle('files', 'myconfig.conf')
# Find a template
tmpl = self._find_needle('templates', 'myconfig.conf.j2')
return result
Action Plugin with Check Mode
class ActionModule(ActionBase):
def run(self, tmp=None, task_vars=None):
result = super().run(tmp, task_vars)
check_mode = self._play_context.check_mode
# Get current state
current = self._execute_module(
module_name='ansible.builtin.stat',
module_args={'path': self._task.args['dest']},
task_vars=task_vars,
)
needs_change = not current.get('stat', {}).get('exists', False)
if check_mode:
result['changed'] = needs_change
result['msg'] = 'Would create file' if needs_change else 'File exists'
return result
if needs_change:
# Actually make the change
result = self._execute_module(
module_name='ansible.builtin.copy',
module_args={
'content': self._task.args['content'],
'dest': self._task.args['dest'],
},
task_vars=task_vars,
)
return result
Multi-Host Coordination
class ActionModule(ActionBase):
"""Action plugin that coordinates across hosts."""
TRANSFERS_FILES = False
def run(self, tmp=None, task_vars=None):
result = super().run(tmp, task_vars)
# Access host variables
hostname = task_vars.get('inventory_hostname')
hostvars = task_vars.get('hostvars', {})
groups = task_vars.get('groups', {})
# Get variable from another host
db_host = groups.get('databases', ['db1'])[0]
db_ip = hostvars.get(db_host, {}).get('ansible_host', db_host)
# Use it in module execution
result = self._execute_module(
module_name='ansible.builtin.template',
module_args={
'src': 'app.conf.j2',
'dest': '/etc/app/config.yml',
},
task_vars={**task_vars, 'db_server_ip': db_ip},
)
return result
Testing Action Plugins
# tests/unit/plugins/action/test_my_action.py
import pytest
from unittest.mock import MagicMock, patch
from plugins.action.my_action import ActionModule
@pytest.fixture
def action_module():
task = MagicMock()
task.args = {'src': 'test.conf', 'dest': '/etc/test.conf'}
connection = MagicMock()
play_context = MagicMock()
play_context.check_mode = False
action = ActionModule(
task=task,
connection=connection,
play_context=play_context,
loader=MagicMock(),
templar=MagicMock(),
shared_loader_obj=MagicMock(),
)
return action
def test_requires_src_and_dest(action_module):
action_module._task.args = {}
result = action_module.run(task_vars={})
assert result['failed'] is True
assert 'required' in result['msg']
Troubleshooting
Plugin not found:
# Check plugin search path
ansible-config dump | grep ACTION_PLUGINS
# Verify file permissions
ls -la plugins/action/my_action.py
# Class must be named ActionModule
grep "class ActionModule" plugins/action/my_action.py
Module vs action plugin confusion:
- Action plugin filename must match the module name
- If
my_modulemodule exists,my_module.pyaction plugin wraps it automatically
Related Articles
- Ansible Custom Modules
- Ansible Callback Plugins
- Ansible Filter Plugins
- Ansible Connection Plugins
- Ansible Cache Plugins
Conclusion
Action plugins give you full control over task execution on the controller side. Use them when you need to validate before deploying, coordinate across hosts, or implement complex transfer logic. Combined with custom modules, they let you extend Ansible's task execution model to handle any workflow.