
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.