
Quand aucun plugin officiel n'existe (CMDB maison, fichier exotique, API legacy), un script qui retourne le bon JSON fait l'affaire. Bash, Python, Go, Rust, peu importe le langage tant que le format de sortie respecte le contrat Ansible. C'est le fallback universel : si vous savez programmer, vous savez créer un inventaire dynamique.
Cette page couvre le format JSON exact attendu, les hooks --list et --host que votre script doit implémenter, des exemples concrets en bash et Python, et les garde-fous sécurité indispensables.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Le format JSON exact d'un inventaire Ansible.
- Implémenter les hooks
--listet--hostdans un script. - Écrire un script bash simple (~30 lignes).
- Écrire un script Python avec gestion d'erreurs et cache (~80 lignes).
- Sécuriser un script custom (permissions, validation entrée).
Prérequis
Section intitulée « Prérequis »- Avoir lu Concepts des plugins d'inventaire.
- Connaître JSON et bash ou Python au niveau basique.
Quand utiliser un script custom
Section intitulée « Quand utiliser un script custom »Un script custom est justifié quand :
- Aucun plugin officiel ne couvre votre source (CMDB maison, fichier ERP, base de données interne).
- Le plugin officiel est trop limité pour vos besoins (ex: NetBox sans le filtre que vous voulez).
- Vous voulez prototyper rapidement avant d'écrire un vrai plugin Python.
À l'inverse, utilisez un plugin officiel quand :
- Le plugin existe (libvirt, AWS, Azure, GCP, NetBox, Proxmox).
- Vous travaillez en équipe, un script maison est plus difficile à maintenir.
- La sécurité prime, un plugin signé Galaxy est plus fiable qu'un script local.
Le format JSON Ansible
Section intitulée « Le format JSON Ansible »Tout script d'inventaire doit retourner ce format quand il est appelé avec --list :
{ "_meta": { "hostvars": { "web1.lab": { "ansible_host": "10.10.20.21", "ansible_user": "ansible", "custom_var": "valeur" }, "web2.lab": { "ansible_host": "10.10.20.22" } } }, "all": { "children": ["webservers", "dbservers"] }, "webservers": { "hosts": ["web1.lab", "web2.lab"], "vars": { "http_port": 8080 } }, "dbservers": { "hosts": ["db1.lab"] }}Sections :
_meta.hostvars: variables par host (équivalenthost_vars/<host>.yml).<groupe>.hosts: liste des hôtes du groupe.<groupe>.children: sous-groupes.<groupe>.vars: variables du groupe (équivalentgroup_vars/<groupe>.yml).
Les deux hooks attendus
Section intitulée « Les deux hooks attendus »--list (mandatory)
Section intitulée « --list (mandatory) »Retourne tout l'inventaire en une fois. C'est l'appel principal de Ansible :
$ ./mon_script.py --list{ ... JSON complet ... }--host <hostname> (optionnel mais préféré)
Section intitulée « --host <hostname> (optionnel mais préféré) »Retourne les variables d'un seul host. Si non implémenté, Ansible utilise _meta.hostvars :
$ ./mon_script.py --host web1.lab{ "ansible_host": "10.10.20.21", "ansible_user": "ansible" }Préférer _meta.hostvars dans --list : c'est plus rapide (un seul appel au lieu de N), et c'est le pattern moderne. --host reste là pour compatibilité.
Exemple bash minimal
Section intitulée « Exemple bash minimal »Cas d'usage : un fichier CSV avec hostname, IP, role :
web1.lab,10.10.20.21,webserverweb2.lab,10.10.20.22,webserverdb1.lab,10.10.20.31,dbserverScript inventory/from_csv.sh :
#!/usr/bin/env bash# Inventaire Ansible depuis un fichier CSV.# Format CSV : hostname,ip,roleset -euo pipefail
CSV="$(dirname "$0")/../data/hosts.csv"
if [[ "${1:-}" == "--list" ]]; then jq -nR --rawfile csv "$CSV" ' [ ($csv | split("\n") | map(select(length > 0))) | .[] | split(",") | { host: .[0], ip: .[1], role: .[2] } ] as $rows | { "_meta": { "hostvars": ([$rows[] | { (.host): { ansible_host: .ip } }] | add) }, "webservers": { "hosts": [$rows[] | select(.role == "webserver") | .host] }, "dbservers": { "hosts": [$rows[] | select(.role == "dbserver") | .host] }, "all": { "children": ["webservers", "dbservers"] } } 'elif [[ "${1:-}" == "--host" ]]; then echo '{}'else echo "Usage: $0 --list|--host <hostname>" >&2 exit 1fiRendre exécutable :
chmod 0750 inventory/from_csv.shTester :
ansible-inventory -i inventory/from_csv.sh --graphSortie :
@all: |--@webservers: | |--web1.lab | |--web2.lab |--@dbservers: | |--db1.labAvantage : 30 lignes, dépendance unique à jq (souvent déjà installé). Limite : un bug dans le CSV (virgule dans un champ) casse tout.
Exemple Python avec validation
Section intitulée « Exemple Python avec validation »Plus robuste, gestion d'erreurs, cache simple :
#!/usr/bin/env python3"""Inventaire dynamique Ansible depuis une API maison."""
import argparseimport jsonimport osimport sysimport timefrom pathlib import Pathfrom urllib.request import urlopen, Request
API_URL = os.environ.get("CMDB_API_URL", "https://cmdb.example.com/api/hosts")API_TOKEN = os.environ.get("CMDB_API_TOKEN")CACHE_FILE = Path("/tmp/ansible_cmdb_cache.json")CACHE_TTL = 300 # 5 minutes
def fetch_from_api(): """Interroge l'API CMDB et retourne la liste brute.""" if not API_TOKEN: print("ERROR: CMDB_API_TOKEN non défini", file=sys.stderr) sys.exit(1)
req = Request(API_URL, headers={"Authorization": f"Bearer {API_TOKEN}"}) with urlopen(req, timeout=10) as response: return json.load(response)
def load_cache(): """Retourne le cache si frais, sinon None.""" if not CACHE_FILE.exists(): return None if time.time() - CACHE_FILE.stat().st_mtime > CACHE_TTL: return None return json.loads(CACHE_FILE.read_text())
def save_cache(data): """Sauve l'inventaire en cache.""" CACHE_FILE.write_text(json.dumps(data))
def build_inventory(hosts): """Transforme la liste API en format JSON Ansible.""" inventory = { "_meta": {"hostvars": {}}, "all": {"children": []}, }
for host in hosts: name = host["hostname"] # host_vars inventory["_meta"]["hostvars"][name] = { "ansible_host": host["ip"], "ansible_user": host.get("ssh_user", "ansible"), "datacenter": host.get("dc", "unknown"), "owner": host.get("owner", "ops"), } # Groupes par role role_group = f"role_{host['role']}" if role_group not in inventory: inventory[role_group] = {"hosts": []} inventory["all"]["children"].append(role_group) inventory[role_group]["hosts"].append(name)
# Groupe par DC dc_group = f"dc_{host.get('dc', 'unknown')}" if dc_group not in inventory: inventory[dc_group] = {"hosts": []} inventory["all"]["children"].append(dc_group) inventory[dc_group]["hosts"].append(name)
return inventory
def main(): parser = argparse.ArgumentParser() parser.add_argument("--list", action="store_true") parser.add_argument("--host", type=str) args = parser.parse_args()
if args.list: # Cache d'abord inventory = load_cache() if inventory is None: try: hosts = fetch_from_api() inventory = build_inventory(hosts) save_cache(inventory) except Exception as e: print(f"ERROR fetching API: {e}", file=sys.stderr) sys.exit(1) print(json.dumps(inventory, indent=2)) elif args.host: # Avec _meta.hostvars dans --list, ce hook retourne {} print(json.dumps({})) else: parser.print_help() sys.exit(1)
if __name__ == "__main__": main()Rendre exécutable et tester :
chmod 0750 inventory/from_cmdb.pyexport CMDB_API_TOKEN="le-token"ansible-inventory -i inventory/from_cmdb.py --graphVous obtenez un inventaire dynamique complet, caché, avec gestion d'erreurs. Soit 80 lignes pour quelque chose qui en vaudrait 200 sans Python.
Sécurité, règles non-négociables
Section intitulée « Sécurité, règles non-négociables »Un script d'inventaire s'exécute à chaque commande Ansible. Risque de payload caché : permissions strictes mandatory.
Permissions du fichier
Section intitulée « Permissions du fichier »chmod 0750 inventory/mon_script.py # rwxr-x--- (owner + group seulement)chown ansible:ansible inventory/mon_script.pyJamais 0755 (world-readable + executable) ni 0777. Un attaquant ne doit pas pouvoir lire votre script (qui peut contenir des chemins, URLs internes) ni le modifier.
Pas de secrets en clair
Section intitulée « Pas de secrets en clair »# ❌ NE PAS FAIREAPI_TOKEN = "abcdef1234567890"
# ✅ Variables d'envAPI_TOKEN = os.environ.get("CMDB_API_TOKEN")
# ✅ Ou Vault Ansible (lookup au runtime)# nécessite une intégration plus complexe — préférer var d'envValidation des entrées
Section intitulée « Validation des entrées »# Si l'API retourne du contenu malveillant, validationhostname = host["hostname"]if not re.match(r'^[a-zA-Z0-9.-]+$', hostname): sys.stderr.write(f"Hostname invalide: {hostname}\n") continue # skip ce host plutôt que crasherUne CMDB compromise pourrait retourner des hostnames avec ;rm -rf /, votre script doit refuser proprement.
Logs séparés
Section intitulée « Logs séparés »import logginglogging.basicConfig( filename="/var/log/ansible_inventory.log", level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s",)logging.info(f"Inventaire généré: {len(hosts)} hosts")Audit en cas d'incident : qui a appelé l'inventaire, quand, avec quel résultat.
Code review obligatoire
Section intitulée « Code review obligatoire »Tout script d'inventaire ajouté au repo doit passer une code review dédiée. Vérifier :
- Pas de credentials en clair.
- Permissions correctes.
- Validation des entrées.
- Pas de
eval(),exec(),os.system()non justifié. - Logging activé.
Pièges courants
Section intitulée « Pièges courants »| Symptôme | Cause | Fix |
|---|---|---|
Failed to parse inventory script | Permissions trop laxistes ou non exécutable | chmod 0750 + chmod +x |
| Format JSON invalide | Erreur de syntaxe dans la sortie | Tester ./script --list | jq empty |
| Lent à chaque commande | Pas de cache implémenté | Ajouter cache local (5 min suffisent souvent) |
| Hôtes manquants | Erreur silencieuse dans la boucle | Ajouter try/except + log explicite |
| Variables non lues | _meta.hostvars absent du JSON | Toujours inclure _meta.hostvars même vide |
| Crash sur valeur null | Pas de default() | Validation et default() Python systématiques |
Quand basculer vers un vrai plugin Python
Section intitulée « Quand basculer vers un vrai plugin Python »Si votre script dépasse 200 lignes, ou si vous avez besoin de :
- Configuration via fichier YAML (comme les plugins officiels).
- Cache plugin (
cache_plugin: jsonfile). groups:etkeyed_groups:Jinja.
Alors écrivez un vrai plugin Python dans une collection. C'est plus de travail initial, mais le résultat est réutilisable par d'autres équipes et publiable sur Galaxy. Voir la doc Ansible : Developing dynamic inventory.
À retenir
Section intitulée « À retenir »- Format JSON Ansible =
_meta.hostvars+ groupes avechosts,children,vars. --listmandatory,--hostoptionnel si_meta.hostvarsest complet.- Bash + jq pour des sources simples (CSV, fichier texte) ; Python pour API.
- Permissions strictes (
0750), secrets via variables d'env, validation des entrées. - Cache local mandatory dès qu'il y a un appel API.
- Code review obligatoire avant de merger un nouveau script.
- Plugin Python officiel > script si > 200 lignes ou multi-équipe.