Aller au contenu
English
English
Infrastructure as Code medium

Script custom d'inventaire Ansible : format JSON, --list/--host, exemples bash/Python

60 min de lecture

Logo Ansible

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.

  • Le format JSON exact d'un inventaire Ansible.
  • Implémenter les hooks --list et --host dans 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).

La question n'est pas de savoir si un script peut faire le travail, mais s'il doit le faire. Un plugin publié apporte sa configuration par fichier, son cache et sa relecture par la communauté ; un script maison apporte la liberté de viser n'importe quelle source, au prix d'une maintenance qui repose entièrement sur vous. Les deux listes suivantes donnent les critères de décision, dans un sens puis dans l'autre.

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.

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 (équivalent host_vars/<host>.yml).
  • <groupe>.hosts : liste des hôtes du groupe.
  • <groupe>.children : sous-groupes.
  • <groupe>.vars : variables du groupe (équivalent group_vars/<groupe>.yml).

Ansible appelle le script en ligne de commande et lit sa sortie standard : le contrat tient donc en deux appels et un format. Le premier est obligatoire et rend tout l'inventaire, le second ne sert qu'à récupérer les variables d'une machine et devient inutile dès que la section dédiée aux variables d'hôte est complète.

Retourne tout l'inventaire en une fois. C'est l'appel principal de Ansible :

Fenêtre de terminal
$ ./mon_script.py --list
{ ... JSON complet ... }

Retourne les variables d'un seul host. Si non implémenté, Ansible utilise _meta.hostvars :

Fenêtre de terminal
$ ./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é.

Cas d'usage : un fichier CSV avec hostname, IP, role :

data/hosts.csv
web1.lab,10.10.20.21,webserver
web2.lab,10.10.20.22,webserver
db1.lab,10.10.20.31,dbserver

Script inventory/from_csv.sh :

#!/usr/bin/env bash
# Inventaire Ansible depuis un fichier CSV.
# Format CSV : hostname,ip,role
set -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 1
fi

Rendre exécutable :

Fenêtre de terminal
chmod 0750 inventory/from_csv.sh

Tester :

Fenêtre de terminal
ansible-inventory -i inventory/from_csv.sh --graph

Sortie :

@all:
|--@webservers:
| |--web1.lab
| |--web2.lab
|--@dbservers:
| |--db1.lab

Avantage : 30 lignes, dépendance unique à jq (souvent déjà installé). Limite : un bug dans le CSV (virgule dans un champ) casse tout.

Plus robuste, gestion d'erreurs, cache simple :

#!/usr/bin/env python3
"""Inventaire dynamique Ansible depuis une API maison."""
import argparse
import json
import os
import sys
import time
from pathlib import Path
from 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 :

Fenêtre de terminal
chmod 0750 inventory/from_cmdb.py
export CMDB_API_TOKEN="le-token"
ansible-inventory -i inventory/from_cmdb.py --graph

Vous obtenez un inventaire dynamique complet, caché, avec gestion d'erreurs. Soit 80 lignes pour quelque chose qui en vaudrait 200 sans Python.

Un script d'inventaire s'exécute à chaque commande Ansible. Risque de payload caché : permissions strictes mandatory.

Fenêtre de terminal
chmod 0750 inventory/mon_script.py # rwxr-x--- (owner + group seulement)
chown ansible:ansible inventory/mon_script.py

Jamais 0755 (world-readable + exécutable) ni 0777. Un attaquant ne doit pas pouvoir lire votre script (qui peut contenir des chemins, URLs internes) ni le modifier.

Un identifiant écrit dans le script part avec lui dans le dépôt, dans les sauvegardes et dans chaque copie de travail. Les trois formes ci-dessous montrent le contre-exemple à ne pas reproduire, puis la lecture depuis l'environnement, qui reste la solution la plus simple ici, le déchiffrement par le coffre supposant une intégration nettement plus lourde.

# ❌ NE PAS FAIRE
API_TOKEN = "abcdef1234567890"
# ✅ Variables d'env
API_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'env
# Si l'API retourne du contenu malveillant, validation
hostname = 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 crasher

Une CMDB compromise pourrait retourner des hostnames avec ;rm -rf /, votre script doit refuser proprement.

import logging
logging.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.

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

Les deux premières lignes empêchent le script de s'exécuter du tout, les quatre suivantes le laissent tourner en produisant un inventaire faux. Ce second cas est le plus coûteux : une erreur silencieuse dans une boucle retire des machines sans que rien ne le signale, et le playbook s'applique alors à un parc incomplet. D'où la règle de tester la sortie du script avec un validateur JSON avant de la confier à Ansible.

SymptômeCauseFix
Failed to parse inventory scriptPermissions trop laxistes ou non exécutablechmod 0750 + chmod +x
Format JSON invalideErreur de syntaxe dans la sortieTester ./script --list | jq empty
Lent à chaque commandePas de cache implémentéAjouter cache local (5 min suffisent souvent)
Hôtes manquantsErreur silencieuse dans la boucleAjouter try/except + log explicite
Variables non lues_meta.hostvars absent du JSONToujours inclure _meta.hostvars même vide
Crash sur valeur nullPas de default()Validation et default() Python systématiques

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: et keyed_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.

Vérifiez que l'essentiel de ce guide est acquis. Les questions portent uniquement sur ce qui vient d'être expliqué ici.

Contrôle de connaissances

Validez vos connaissances avec ce quiz interactif

6 questions
6 min.
70% requis

Informations

  • Le chronomètre démarre au clic sur Démarrer
  • Questions à choix multiples, vrai/faux et réponses courtes
  • Vous pouvez naviguer entre les questions
  • Les résultats détaillés sont affichés à la fin

Lance le quiz et démarre le chronomètre

  • Format JSON Ansible = _meta.hostvars + groupes avec hosts, children, vars.
  • --list mandatory, --host optionnel si _meta.hostvars est 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.
  • Trouver le bon module : avant d'écrire du code maison, la méthode pour vérifier qu'un contenu officiel ne couvre pas déjà le besoin.
  • Module copy : transférer un fichier ou écrire un contenu défini dans la tâche, sur les hôtes que votre script vient de découvrir.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens ce site gratuitement, sans publicité, sans profilage et sans compte à créer. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn