Ansible Custom Modules — Write Your Own in Python
Introduction
When Ansible's 6,000+ built-in modules don't cover your specific use case, writing a custom module is the solution. Custom modules let you extend Ansible with your own logic while maintaining idempotency, check mode support, and seamless integration with playbooks.
This guide walks you through creating production-quality custom modules in Python, from basic structure to testing and distribution.
Why Write Custom Modules?
Use cases for custom modules:
- Interact with proprietary APIs not covered by existing modules
- Wrap complex shell commands in an idempotent interface
- Enforce organization-specific business logic
- Create simplified interfaces for common multi-step operations
- Integrate with internal tools and services
Module Structure
Every Ansible module follows a standard structure:
```python #!/usr/bin/python
-- coding: utf-8 --
DOCUMENTATION = r'''
module: my_custom_module short_description: Description of what the module does version_added: "1.0.0" description:
- Detailed description of the module functionality.
- Supports check mode and diff mode.
options:
name:
description:
- Name of the resource to manage. required: true type: str state: description:
- Desired state of the resource. choices: ['present', 'absent'] default: present type: str force: description:
- Force the operation even if the resource exists. type: bool default: false author:
- Your Name (@github_handle) '''
EXAMPLES = r'''
-
name: Create a resource my_custom_module: name: my_resource state: present
-
name: Remove a resource my_custom_module: name: my_resource state: absent force: true '''
RETURN = r''' message: description: Status message from the module. returned: always type: str sample: "Resource created successfully" changed: description: Whether any changes were made. returned: always type: bool original_state: description: State of the resource before module execution. returned: always type: dict '''
from ansible.module_utils.basic import AnsibleModule
def run_module(): # Define the argument spec module_args = dict( name=dict(type='str', required=True), state=dict(type='str', default='present', choices=['present', 'absent']), force=dict(type='bool', default=False), )
# Initialize the module
module = AnsibleModule(
argument_spec=module_args,
supports_check_mode=True,
)
# Collect parameters
name = module.params['name']
state = module.params['state']
force = module.params['force']
# Initialize result
result = dict(
changed=False,
message='',
original_state={},
)
# Check current state
current_state = get_resource_state(name)
result['original_state'] = current_state
# Determine if changes are needed
if state == 'present' and not current_state.get('exists'):
result['changed'] = True
result['message'] = f'Resource {name} will be created'
elif state == 'absent' and current_state.get('exists'):
result['changed'] = True
result['message'] = f'Resource {name} will be removed'
# Check mode — report what would change without making changes
if module.check_mode:
module.exit_json(**result)
# Apply changes
if result['changed']:
try:
if state == 'present':
create_resource(name)
result['message'] = f'Resource {name} created successfully'
elif state == 'absent':
delete_resource(name, force=force)
result['message'] = f'Resource {name} removed successfully'
except Exception as e:
module.fail_json(msg=f'Operation failed: {str(e)}', **result)
module.exit_json(**result)
def get_resource_state(name): """Check if the resource exists.""" # Replace with your actual logic return {'exists': False, 'name': name}
def create_resource(name): """Create the resource.""" # Replace with your actual creation logic pass
def delete_resource(name, force=False): """Delete the resource.""" # Replace with your actual deletion logic pass
def main(): run_module()
if name == 'main': main() ```
Argument Specification
The `argument_spec` dictionary defines module parameters:
Common Parameter Types
| Type | Description | Example |
|---|---|---|
| `str` | String value | `name=dict(type='str')` |
| `int` | Integer value | `port=dict(type='int', default=8080)` |
| `bool` | Boolean value | `force=dict(type='bool', default=False)` |
| `list` | List of values | `tags=dict(type='list', elements='str')` |
| `dict` | Dictionary | `config=dict(type='dict')` |
| `path` | File path (expanded) | `dest=dict(type='path')` |
| `float` | Floating point | `timeout=dict(type='float')` |
Advanced Parameter Features
```python module_args = dict( # Required parameter name=dict(type='str', required=True),
# Parameter with choices
state=dict(type='str', choices=['present', 'absent', 'restarted']),
# Parameter with aliases
dest=dict(type='path', aliases=['destination', 'target']),
# Mutually exclusive groups
# (defined separately in AnsibleModule)
# No-log parameter (passwords, secrets)
password=dict(type='str', no_log=True),
# Nested spec (sub-options)
config=dict(
type='dict',
options=dict(
host=dict(type='str', default='localhost'),
port=dict(type='int', default=5432),
),
),
)
module = AnsibleModule( argument_spec=module_args, mutually_exclusive=[ ('file', 'content'), ], required_one_of=[ ('file', 'content'), ], required_together=[ ('username', 'password'), ], supports_check_mode=True, ) ```
Check Mode Support
Check mode (`--check`) lets users preview changes without applying them:
```python module = AnsibleModule( argument_spec=module_args, supports_check_mode=True, )
Your logic determines what WOULD change
result['changed'] = needs_update(current, desired)
Exit early in check mode — don't make changes
if module.check_mode: module.exit_json(**result)
Actually apply changes
apply_changes(current, desired) module.exit_json(**result) ```
Diff Mode Support
Diff mode (`--diff`) shows what changed:
```python if module._diff: result['diff'] = dict( before=current_config, after=desired_config, ) ```
Return Values
Modules communicate results through `exit_json` and `fail_json`:
```python
Success
module.exit_json( changed=True, msg='Resource updated', resource_id='abc123', diff={'before': old_state, 'after': new_state}, )
Failure
module.fail_json( msg='Cannot connect to API', status_code=503, response_body=response.text, ) ```
Module Placement
Place custom modules in one of these locations:
```
Project-level (recommended)
my_project/ ├── library/ │ └── my_custom_module.py ├── playbooks/ │ └── site.yml └── ansible.cfg
In ansible.cfg
[defaults] library = ./library
In a collection
collections/ └── ansible_collections/ └── myorg/ └── mycollection/ └── plugins/ └── modules/ └── my_custom_module.py ```
Running Shell Commands
Use `module.run_command()` instead of `subprocess`:
```python def get_package_version(module, package_name): """Get installed package version using rpm.""" rc, stdout, stderr = module.run_command( ['rpm', '-q', '--queryformat', '%{VERSION}', package_name] )
if rc != 0:
return None
return stdout.strip()
```
Making HTTP Requests
Use `ansible.module_utils.urls` for HTTP requests:
```python from ansible.module_utils.urls import open_url import json
def api_request(module, url, method='GET', data=None): """Make an API request.""" headers = { 'Content-Type': 'application/json', 'Authorization': f'Bearer {module.params["api_token"]}', }
try:
response = open_url(
url,
method=method,
data=json.dumps(data) if data else None,
headers=headers,
validate_certs=module.params.get('validate_certs', True),
)
return json.loads(response.read())
except Exception as e:
module.fail_json(msg=f'API request failed: {str(e)}')
```
Testing Your Module
Unit Testing
```python
tests/unit/plugins/modules/test_my_module.py
import pytest from unittest.mock import patch, MagicMock from ansible_collections.myorg.mycollection.plugins.modules import my_custom_module
@pytest.fixture def module_args(): return { 'name': 'test_resource', 'state': 'present', 'force': False, }
def test_create_resource(module_args): with patch.object(my_custom_module, 'get_resource_state') as mock_state: mock_state.return_value = {'exists': False} # Test module logic ```
Integration Testing with ansible-test
```yaml
tests/integration/targets/my_custom_module/tasks/main.yml
-
name: Create resource (check mode) my_custom_module: name: test_resource state: present check_mode: true register: check_result
-
name: Verify check mode ansible.builtin.assert: that: - check_result is changed
-
name: Create resource my_custom_module: name: test_resource state: present register: create_result
-
name: Verify creation ansible.builtin.assert: that: - create_result is changed - create_result.message == "Resource test_resource created successfully"
-
name: Create resource again (idempotency) my_custom_module: name: test_resource state: present register: idem_result
-
name: Verify idempotency ansible.builtin.assert: that: - idem_result is not changed ```
Run tests
```bash
Sanity tests
ansible-test sanity --docker
Unit tests
ansible-test units --docker
Integration tests
ansible-test integration my_custom_module --docker ```
Real-World Example: Managing API Resources
```python #!/usr/bin/python
-- coding: utf-8 --
DOCUMENTATION = r'''
module: api_endpoint short_description: Manage API endpoints in a service registry description:
- Create, update, or delete API endpoints in a service registry.
- Supports check mode and diff mode. options: url: description: Registry API URL. required: true type: str api_token: description: Authentication token. required: true type: str no_log: true name: description: Endpoint name. required: true type: str endpoint_url: description: URL of the endpoint. type: str state: description: Desired state. choices: ['present', 'absent'] default: present type: str validate_certs: description: Validate SSL certificates. type: bool default: true author:
- Luca Berton (@lucab85) '''
from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.urls import open_url import json
class ServiceRegistry: def init(self, module): self.module = module self.base_url = module.params['url'].rstrip('/') self.token = module.params['api_token'] self.validate_certs = module.params['validate_certs']
def _request(self, path, method='GET', data=None):
url = f'{self.base_url}{path}'
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {self.token}',
}
try:
resp = open_url(
url, method=method,
data=json.dumps(data) if data else None,
headers=headers,
validate_certs=self.validate_certs,
)
return json.loads(resp.read())
except Exception as e:
if '404' in str(e):
return None
self.module.fail_json(msg=f'API error: {e}')
def get_endpoint(self, name):
return self._request(f'/endpoints/{name}')
def create_endpoint(self, name, url):
return self._request('/endpoints', 'POST', {'name': name, 'url': url})
def update_endpoint(self, name, url):
return self._request(f'/endpoints/{name}', 'PUT', {'url': url})
def delete_endpoint(self, name):
return self._request(f'/endpoints/{name}', 'DELETE')
def main(): module = AnsibleModule( argument_spec=dict( url=dict(type='str', required=True), api_token=dict(type='str', required=True, no_log=True), name=dict(type='str', required=True), endpoint_url=dict(type='str'), state=dict(type='str', default='present', choices=['present', 'absent']), validate_certs=dict(type='bool', default=True), ), required_if=[ ('state', 'present', ['endpoint_url']), ], supports_check_mode=True, )
registry = ServiceRegistry(module)
name = module.params['name']
state = module.params['state']
endpoint_url = module.params.get('endpoint_url')
current = registry.get_endpoint(name)
result = dict(changed=False, endpoint={})
if state == 'present':
if current is None:
result['changed'] = True
if not module.check_mode:
result['endpoint'] = registry.create_endpoint(name, endpoint_url)
elif current.get('url') != endpoint_url:
result['changed'] = True
if module._diff:
result['diff'] = {
'before': {'url': current.get('url')},
'after': {'url': endpoint_url},
}
if not module.check_mode:
result['endpoint'] = registry.update_endpoint(name, endpoint_url)
elif state == 'absent':
if current is not None:
result['changed'] = True
if not module.check_mode:
registry.delete_endpoint(name)
module.exit_json(**result)
if name == 'main': main() ```
Common Mistakes
1. Not supporting check mode: Always set `supports_check_mode=True` and handle `module.check_mode`.
2. Not being idempotent: Check the current state before making changes. Only report `changed=True` when something actually changes.
3. Using subprocess instead of run_command: `module.run_command()` handles PATH, encoding, and error reporting correctly.
4. Forgetting no_log for secrets: Always mark password/token parameters with `no_log=True`.
5. Not validating input properly: Use `argument_spec` constraints (choices, required_if, mutually_exclusive) instead of manual validation.
Troubleshooting
Module not found: ```bash
Check module search path
ansible-config dump | grep DEFAULT_MODULE_PATH
Verify module is importable
python3 -c "import my_custom_module" ```
Debugging module output: ```bash
Run module directly (for testing)
python3 my_custom_module.py /tmp/args.json
Enable verbose output
ansible-playbook site.yml -vvv ```
Related Articles
- Ansible Filter Plugins Guide
- Ansible Callback Plugins
- Ansible Lookup Plugins
- Ansible Collections Guide
- Ansible Testing with Molecule
Conclusion
Custom modules are Ansible's ultimate extension point. Follow these practices:
- Always support check mode and diff mode
- Be idempotent — check before changing
- Use `argument_spec` for input validation
- Handle errors gracefully with `fail_json`
- Write DOCUMENTATION, EXAMPLES, and RETURN docstrings
- Test with ansible-test for sanity, unit, and integration coverage
Start with simple modules wrapping existing APIs, then build complexity as needed.