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 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

IssueSolution
Script not executingchmod +x script.py; check shebang line
Invalid JSON outputTest with `./script.py --list
Plugin not foundAdd to enable_plugins in ansible.cfg
Stale cacheDelete cache: rm -rf /tmp/ansible-inventory-cache
API timeoutIncrease timeout; enable caching
Empty inventoryCheck API response; verify filters aren't too strict

Best Practices

  1. Use inventory plugins over scripts — better integration, caching, composable
  2. Cache results — avoid API calls on every run
  3. Use keyed_groups — auto-create groups from host attributes
  4. Use compose — set ansible_host, ansible_user from source data
  5. Combine static + dynamic — put both in an inventory directory
  6. 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.