Ansible Dynamic Inventory Script — Custom Host Sources
Introduction
Static inventory files don't scale when your infrastructure changes constantly. Dynamic inventory pulls host information from external sources — cloud provider APIs, CMDBs, databases, or any HTTP API. Ansible supports both inventory scripts (executable programs) and inventory plugins (Python classes). This guide covers both approaches.
Dynamic Inventory JSON Format
Every dynamic inventory must output JSON in this format:
{
"_meta": {
"hostvars": {
"web01": {
"ansible_host": "192.168.1.10",
"ansible_user": "deploy",
"app_port": 8080
},
"web02": {
"ansible_host": "192.168.1.11",
"ansible_user": "deploy",
"app_port": 8080
},
"db01": {
"ansible_host": "192.168.1.20",
"ansible_user": "admin"
}
}
},
"webservers": {
"hosts": ["web01", "web02"]
},
"databases": {
"hosts": ["db01"]
},
"production": {
"children": ["webservers", "databases"]
}
}
Simple Inventory Script (Bash)
#!/bin/bash
# inventory/hosts.sh — make executable: chmod +x
if [ "$1" == "--list" ]; then
cat << 'EOF'
{
"_meta": {
"hostvars": {
"web01": {"ansible_host": "192.168.1.10"},
"web02": {"ansible_host": "192.168.1.11"},
"db01": {"ansible_host": "192.168.1.20"}
}
},
"webservers": {"hosts": ["web01", "web02"]},
"databases": {"hosts": ["db01"]}
}
EOF
elif [ "$1" == "--host" ]; then
echo '{}'
fi
# Test it
chmod +x inventory/hosts.sh
ansible-inventory -i inventory/hosts.sh --list
ansible all -i inventory/hosts.sh -m ping
Python Inventory Script
#!/usr/bin/env python3
"""Dynamic inventory from an API."""
import json
import sys
import urllib.request
API_URL = "https://cmdb.example.com/api/v1/servers"
API_TOKEN = "your-api-token"
def get_inventory():
req = urllib.request.Request(
API_URL,
headers={"Authorization": f"Bearer {API_TOKEN}"}
)
with urllib.request.urlopen(req) as resp:
servers = json.loads(resp.read())
inventory = {
"_meta": {"hostvars": {}},
"all": {"hosts": []}
}
for server in servers:
hostname = server["hostname"]
inventory["all"]["hosts"].append(hostname)
inventory["_meta"]["hostvars"][hostname] = {
"ansible_host": server["ip_address"],
"ansible_user": server.get("ssh_user", "deploy"),
"server_role": server.get("role", "unknown"),
"environment": server.get("environment", "unknown"),
}
# Group by role
role = server.get("role", "ungrouped")
if role not in inventory:
inventory[role] = {"hosts": []}
inventory[role]["hosts"].append(hostname)
# Group by environment
env = server.get("environment", "unknown")
if env not in inventory:
inventory[env] = {"hosts": []}
inventory[env]["hosts"].append(hostname)
return inventory
def get_host(hostname):
inv = get_inventory()
return inv["_meta"]["hostvars"].get(hostname, {})
if __name__ == "__main__":
if len(sys.argv) == 2 and sys.argv[1] == "--list":
print(json.dumps(get_inventory(), indent=2))
elif len(sys.argv) == 3 and sys.argv[1] == "--host":
print(json.dumps(get_host(sys.argv[2]), indent=2))
else:
print("Usage: --list or --host <hostname>", file=sys.stderr)
sys.exit(1)
Inventory Plugin (Recommended)
Inventory plugins are the modern approach — they integrate with Ansible's caching, support compose and keyed_groups, and don't require separate scripts.
# plugins/inventory/my_cmdb.py
from ansible.plugins.inventory import BaseInventoryPlugin, Constructable, Cacheable
import json
import urllib.request
DOCUMENTATION = '''
name: my_cmdb
plugin_type: inventory
short_description: CMDB inventory source
options:
api_url:
description: CMDB API endpoint
required: true
api_token:
description: API authentication token
required: true
env:
- name: CMDB_API_TOKEN
'''
class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable):
NAME = 'my_cmdb'
def verify_file(self, path):
valid = False
if super().verify_file(path):
if path.endswith(('cmdb.yml', 'cmdb.yaml')):
valid = True
return valid
def parse(self, inventory, loader, path, cache=True):
super().parse(inventory, loader, path, cache)
self._read_config_data(path)
api_url = self.get_option('api_url')
api_token = self.get_option('api_token')
req = urllib.request.Request(
api_url,
headers={"Authorization": f"Bearer {api_token}"}
)
with urllib.request.urlopen(req) as resp:
servers = json.loads(resp.read())
for server in servers:
hostname = server['hostname']
self.inventory.add_host(hostname)
self.inventory.set_variable(hostname, 'ansible_host', server['ip'])
self.inventory.set_variable(hostname, 'ansible_user', server.get('user', 'deploy'))
# Add to groups
group = server.get('role', 'ungrouped')
self.inventory.add_group(group)
self.inventory.add_host(hostname, group=group)
env_group = server.get('environment', 'unknown')
self.inventory.add_group(env_group)
self.inventory.add_host(hostname, group=env_group)
# inventory/cmdb.yml — plugin configuration file
plugin: my_cmdb
api_url: https://cmdb.example.com/api/v1/servers
api_token: "{{ lookup('env', 'CMDB_API_TOKEN') }}"
# ansible.cfg — enable your plugin
[inventory]
enable_plugins = my_cmdb, host_list, yaml, ini, auto
Built-in Cloud Inventory Plugins
# inventory/aws_ec2.yml
plugin: amazon.aws.aws_ec2
regions:
- us-east-1
- eu-west-1
filters:
tag:ManagedBy: ansible
instance-state-name: running
keyed_groups:
- key: tags.Environment
prefix: env
separator: _
- key: instance_type
prefix: type
- key: placement.region
prefix: region
compose:
ansible_host: public_ip_address | default(private_ip_address)
ansible_user: "'ubuntu'"
hostnames:
- tag:Name
- private-dns-name
# inventory/gcp_compute.yml
plugin: google.cloud.gcp_compute
projects:
- my-project-id
zones:
- us-central1-a
filters:
- status = RUNNING
- labels.managed-by = ansible
keyed_groups:
- key: labels.environment
prefix: env
compose:
ansible_host: networkInterfaces[0].accessConfigs[0].natIP | default(networkInterfaces[0].networkIP)
Combining Inventories
# Use multiple inventory sources
ansible-playbook site.yml -i inventory/
# Directory structure
inventory/
├── static_hosts.yml # Static inventory
├── aws_ec2.yml # AWS dynamic
├── gcp_compute.yml # GCP dynamic
└── group_vars/
└── all.yml
Caching
# ansible.cfg — cache dynamic inventory results
[inventory]
cache = true
cache_plugin = jsonfile
cache_connection = /tmp/ansible-inventory-cache
cache_timeout = 3600
Testing Your Inventory
# List all hosts
ansible-inventory -i inventory/ --list
# Graph view
ansible-inventory -i inventory/ --graph
# Specific host variables
ansible-inventory -i inventory/ --host web01
# Test connectivity
ansible all -i inventory/ -m ping
Troubleshooting
| Issue | Solution |
|---|---|
| Script not executing | chmod +x script.py; check shebang line |
| Invalid JSON output | Test with `./script.py --list |
| Plugin not found | Add to enable_plugins in ansible.cfg |
| Stale cache | Delete cache: rm -rf /tmp/ansible-inventory-cache |
| API timeout | Increase timeout; enable caching |
| Empty inventory | Check API response; verify filters aren't too strict |
Best Practices
- Use inventory plugins over scripts — better integration, caching, composable
- Cache results — avoid API calls on every run
- Use
keyed_groups— auto-create groups from host attributes - Use
compose— setansible_host,ansible_userfrom source data - Combine static + dynamic — put both in an inventory directory
- Test with
--graph— visualize your inventory structure before running playbooks
Conclusion
Dynamic inventory connects Ansible to your source of truth — whether that's AWS, a CMDB, a database, or a custom API. Start with built-in cloud plugins, and write custom plugins when you need to pull from internal systems. The key is making your infrastructure self-describing so playbooks never need hardcoded host lists.