
Cette page montre comment construire un plugin d'inventaire dynamique Ansible pour OUTSCALE qui rivalise en richesse avec amazon.aws.aws_ec2. Le plugin utilise le SDK Python officiel osc-sdk-python et l'API ReadVms, expose les 56 filtres du schéma FiltersVm de la spec OAPI, et hérite des fragments Ansible standards (constructed, inventory_cache) pour offrir keyed_groups, groups conditionnels et compose. Le code complet a été validé sur un compte OUTSCALE de référence (région eu-west-2, 6 VMs Talos) en avril 2026. Page tagguée pour les piliers Well-Architected Operational Excellence (inventaire à jour, IaC) et Security (filtrage par tag, pas de credentials en clair).
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Concevoir un plugin d'inventaire Ansible conforme à la grammaire Ansible (DOCUMENTATION, options, héritage
BaseInventoryPlugin). - Authentifier via
osc-sdk-pythonen gérant la divergence de schéma entre le format de profil OSC (region_name) et le SDK Python (region). - Filtrer les VMs avec les 56 filtres du schéma
FiltersVmde la spec OAPI. - Résoudre
inventory_hostnamepar priorité (VmId,PublicIp,tag:Name,tag:Foo=Bar). - Composer
ansible_host, grouper par état, sous-région, tag, génération TINA, et écrire des groupes conditionnels (prod,bastions, etc.). - Valider le plugin sur un compte réel avec un harness de tests reproductible.
Prérequis
Section intitulée « Prérequis »- Compte OUTSCALE actif avec profil OSC configuré dans
~/.osc/config.json. - Python 3.10+, Ansible ≥ 2.16.
osc-sdk-python≥ 0.40.0 (pip install osc-sdk-python).- Si Ansible est installé via
pipx, injecter le SDK :pipx inject ansible osc-sdk-python.
Pourquoi un plugin dédié plutôt que le plugin AWS
Section intitulée « Pourquoi un plugin dédié plutôt que le plugin AWS »OUTSCALE étant compatible AWS sur l'API EC2, le plugin amazon.aws.aws_ec2 fonctionne en pointant sur l'endpoint Outscale. C'est même la voie courte historique. Mais ce raccourci a trois limites :
- Pas de filtres OUTSCALE-spécifiques —
Lifecycles: spot,NicSubregionNames,BootModes(uefi/legacy),TpmEnabledne sont pas exposés. - Pas de format TINA natif —
vm_typeest exposé sous le mapping AWS-style (t2.small), pas en TINA (tinav5.c2r4p2), ce qui complique leskeyed_groupspar génération. - Pas de profils OSC — le plugin AWS lit
~/.aws/credentials, pas~/.osc/config.json.
Un plugin dédié osc_vm adresse ces trois points et expose une grammaire OUTSCALE native : FiltersVm complet, Placement.SubregionName, format TINA, profils OSC.
Anatomie du plugin
Section intitulée « Anatomie du plugin »Le plugin Ansible suit un modèle bien établi : un fichier Python avec trois éléments — un bloc DOCUMENTATION YAML, un bloc EXAMPLES, et une classe InventoryModule qui hérite de BaseInventoryPlugin, Constructable, Cacheable.
Structure du dossier de lab
Section intitulée « Structure du dossier de lab »Répertoirelab-outscale-inventory/
- .envrc
- ansible.cfg
- requirements.txt
Répertoireinventory_plugins/
- osc_vm.py
Répertoireexamples/
- 01-basic.osc_vm.yml
- 02-filters.osc_vm.yml
- 03-keyed-groups.osc_vm.yml
- 04-full-featured.osc_vm.yml
Répertoireplaybooks/
- ping-by-tag.yml
Répertoiretests/
- run-tests.sh
ansible.cfg — activer le plugin local
Section intitulée « ansible.cfg — activer le plugin local »[defaults]inventory_plugins = ./inventory_pluginshost_key_checking = Falsestdout_callback = yaml
[inventory]enable_plugins = osc_vm, host_list, script, auto, yaml, ini, tomlcache = Truecache_plugin = jsonfilecache_connection = ./.osc_vm_cachecache_timeout = 3600osc_vm doit être listé avant auto et yaml pour que les fichiers *.osc_vm.yml ne soient pas traités comme du YAML inventaire générique.
Le plugin Python
Section intitulée « Le plugin Python »Bloc DOCUMENTATION
Section intitulée « Bloc DOCUMENTATION »Le bloc YAML décrit toutes les options du plugin pour la documentation Ansible automatique.
DOCUMENTATION = r"""name: osc_vmplugin_type: inventoryshort_description: "Inventaire dynamique des VMs OUTSCALE via le SDK Python"description: - "Récupère la liste des VMs depuis l'API OUTSCALE OAPI et les expose comme hôtes Ansible." - "Le fichier d'inventaire doit se terminer par .osc_vm.yml ou .osc_vm.yaml pour être détecté."extends_documentation_fragment: - constructed - inventory_cacheoptions: plugin: description: "Marque la sélection du plugin — toujours osc_vm." required: true choices: ['osc_vm'] profiles: description: "Liste des profils OSC à interroger." type: list elements: str default: [] regions: description: "Régions à interroger. Si vide, lue depuis le profil. EU only — eu-west-2 et cloudgouv-eu-west-1." type: list elements: str default: [] filters: description: "Dictionnaire de filtres OAPI ReadVms." type: dict default: {} hostnames: description: "Candidats inventory_hostname évalués dans l'ordre." type: list elements: raw default: - PublicIp - "PrivateIps[0]" - VmId hostvars_prefix: description: "Préfixe ajouté aux hostvars." type: str default: "" strict_permissions: description: "Si false, ignore silencieusement les régions inaccessibles." type: bool default: true"""Classe InventoryModule
Section intitulée « Classe InventoryModule »from ansible.errors import AnsibleErrorfrom ansible.plugins.inventory import BaseInventoryPlugin, Constructable, Cacheablefrom ansible.utils.display import Display
try: from osc_sdk_python import Gateway HAS_OSC_SDK = True OSC_SDK_IMPORT_ERROR = Noneexcept ImportError as exc: HAS_OSC_SDK = False OSC_SDK_IMPORT_ERROR = exc
class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable): NAME = "osc_vm"
def verify_file(self, path): """Le plugin n'accepte que les fichiers se terminant par .osc_vm.yml/yaml.""" if not super().verify_file(path): return False return path.endswith((".osc_vm.yml", ".osc_vm.yaml"))
def parse(self, inventory, loader, path, cache=True): if not HAS_OSC_SDK: raise AnsibleError( "Le SDK Python OUTSCALE n'est pas installé : " "`pip install osc-sdk-python` (erreur : %s)" % OSC_SDK_IMPORT_ERROR ) super().parse(inventory, loader, path) self._read_config_data(path)
# Cache (clé propre au plugin pour éviter les collisions). cache_key = self.get_cache_key(path) user_cache_setting = self.get_option("cache") attempt_to_read = user_cache_setting and cache cache_needs_update = user_cache_setting and not cache
if attempt_to_read: try: results = self._cache[cache_key] self._populate(results) return except KeyError: cache_needs_update = True
results = self._collect_vms() if cache_needs_update: self._cache[cache_key] = results self._populate(results)Authentification — gérer region_name
Section intitulée « Authentification — gérer region_name »Pilier Operational Excellence. Le SDK osc-sdk-python 0.40.0 rejette silencieusement les profils contenant la clé legacy region_name (héritée d'oapi-cli) en raison d'un TypeError: unexpected keyword arguments. Comme l'exception est swallow par from_standard_configuration, le profil utilisateur n'est jamais chargé et la suite du code échoue avec un cryptique TypeError: can only concatenate str (not "NoneType") to str.
Le contournement consiste à lire le fichier de config manuellement et à normaliser les clés avant de les passer au SDK :
@staticmethoddef _load_profile_credentials(profile): """Lit ~/.osc/config.json et normalise vers le format SDK Python.""" import json, os with open(os.path.expanduser("~/.osc/config.json")) as fh: config = json.load(fh) section = config.get(profile) if section is None: raise AnsibleError("Profil '%s' introuvable" % profile)
creds = {} for key in ("access_key", "secret_key", "x509_client_cert", "x509_client_key", "tls_skip_verify", "login", "password"): if key in section: creds[key] = section[key]
# region_name (oapi-cli legacy) → region (format SDK Python). if "region_name" in section: creds["region"] = section["region_name"] elif "region" in section: creds["region"] = section["region"]
# https: true/false → protocol: "https"/"http" if "https" in section: creds["protocol"] = "https" if section["https"] else "http" else: creds["protocol"] = "https"
return credsAppel ReadVms
Section intitulée « Appel ReadVms »@staticmethoddef _read_vms(profile, region, filters): creds = InventoryModule._load_profile_credentials(profile) if region: creds["region"] = region
# Gateway() est un context manager — `with` libère proprement les ressources. # Le SDK dispatche tout attribut inconnu en tant qu'action OAPI ; # éviter `gw.close()` qui serait interprété comme un appel API "close". with Gateway(**creds) as gw: payload = {"Filters": filters} if filters else {} response = gw.ReadVms(**payload) return list(response.get("Vms", []))Résolution de inventory_hostname
Section intitulée « Résolution de inventory_hostname »@staticmethoddef _extract_hostname_field(vm, spec): """Résout une spec hostname (VmId, PublicIp, tag:Name, tag:Foo=Bar, PrivateIps[N]).""" # Tag avec valeur attendue : ne retient la VM que si le tag matche. if spec.startswith("tag:") and "=" in spec: raw = spec[4:] tag_key, expected = raw.split("=", 1) for tag in vm.get("Tags", []) or []: if tag.get("Key") == tag_key and tag.get("Value") == expected: return tag.get("Value", "") return ""
# Tag simple : retourne la valeur du tag. if spec.startswith("tag:"): tag_key = spec[4:] for tag in vm.get("Tags", []) or []: if tag.get("Key") == tag_key: return tag.get("Value", "") return ""
# PrivateIps[N] — index dans la liste des IPs privées. import re m = re.match(r"^PrivateIps\[(\d+)\]$", spec) if m: idx = int(m.group(1)) ips = [] for nic in vm.get("Nics", []) or []: for pip in nic.get("PrivateIps", []) or []: if pip.get("PrivateIp"): ips.append(pip["PrivateIp"]) if not ips and vm.get("PrivateIp"): ips = [vm["PrivateIp"]] return ips[idx] if idx < len(ips) else ""
# Champ Vm direct (PublicIp, PrivateDnsName, VmId, etc.). return str(vm.get(spec, "") or "")Construction des hostvars
Section intitulée « Construction des hostvars »Pilier Operational Excellence. Les hostvars exposées dérivent du schéma Vm de l'OAPI, normalisées en snake_case, avec deux ajouts pratiques :
vm_tags(et nontagsréservé par Ansible) — dict des tags pour usage en Jinja2.private_ips— liste flat des IPs privées multi-NIC.
@staticmethoddef _build_hostvars(vm): hostvars = {}
# Champs flat issus du schéma OAPI Vm. for key in _VM_HOSTVAR_KEYS: if key in vm: hostvars[InventoryModule._snake(key)] = vm[key]
# Tags transformés en dict — `vm_tags` plutôt que `tags` (réservé Ansible). tags_dict = {} for tag in vm.get("Tags", []) or []: tags_dict[tag.get("Key", "")] = tag.get("Value", "") hostvars["vm_tags"] = tags_dict hostvars["tags_raw"] = vm.get("Tags", []) or [] hostvars.pop("tags", None)
# Sous-région dérivée de Placement. placement = vm.get("Placement", {}) or {} if placement.get("SubregionName"): hostvars["subregion"] = placement["SubregionName"]
# Liste plate des IPs privées (utile pour ansible_host derrière bastion). private_ips = [] for nic in vm.get("Nics", []) or []: for pip in nic.get("PrivateIps", []) or []: if pip.get("PrivateIp"): private_ips.append(pip["PrivateIp"]) if not private_ips and vm.get("PrivateIp"): private_ips = [vm["PrivateIp"]] hostvars["private_ips"] = private_ips
return hostvarsFichiers d'inventaire
Section intitulée « Fichiers d'inventaire »Exemple basic — toutes les VMs running
Section intitulée « Exemple basic — toutes les VMs running »plugin: osc_vmprofiles: - defaultfilters: VmStateNames: - runningExemple avec filtres OAPI
Section intitulée « Exemple avec filtres OAPI »plugin: osc_vmprofiles: - defaultregions: - eu-west-2filters: VmStateNames: - running SubregionNames: - eu-west-2ahostnames: - "tag:Name" - PublicIp - VmIdExemple avec keyed_groups
Section intitulée « Exemple avec keyed_groups »plugin: osc_vmprofiles: - defaultfilters: VmStateNames: - runninghostnames: - "tag:Name" - VmIdhostvars_prefix: "osc_"keyed_groups: - prefix: state key: osc_state - prefix: az key: osc_subregion - prefix: tina key: "osc_vm_type | regex_search('^(tinav\\d+)') | default('unknown')" - prefix: app key: "osc_vm_tags['Application'] | default('untagged')" - prefix: env key: "osc_vm_tags['env'] | default('untagged')"Exemple complet avec compose et groups conditionnels
Section intitulée « Exemple complet avec compose et groups conditionnels »plugin: osc_vmprofiles: - defaultregions: - eu-west-2filters: VmStateNames: - runninghostnames: - "tag:Name" - VmIdhostvars_prefix: "osc_"keyed_groups: - prefix: state key: osc_state - prefix: az key: osc_subregion - prefix: tina key: "osc_vm_type | regex_search('^(tinav\\d+)') | default('unknown')" - prefix: env key: "osc_vm_tags['env'] | default('untagged')"groups: prod: "osc_vm_tags.env | default('') == 'prod'" bastions: "'bastion' in (osc_vm_tags.Name | default(''))" has_public_ip: "osc_public_ip is defined and osc_public_ip"compose: ansible_host: "osc_private_ips[0] | default(osc_public_ip)" ansible_user: "'outscale'"Validation sur un compte réel
Section intitulée « Validation sur un compte réel »-
Préparer l'environnement :
Fenêtre de terminal pipx inject ansible osc-sdk-python # si Ansible installé via pipxdirenv allow # charge OSC_PROFILE -
Lancer un inventaire basique :
Fenêtre de terminal ansible-inventory -i examples/01-basic.osc_vm.yml --list | head -20 -
Vérifier les groupes générés :
Fenêtre de terminal ansible-inventory -i examples/04-full-featured.osc_vm.yml --graphSortie attendue (sur compte de référence) :
@all:|--@state_running:| |--talos-prod-vault| |--talos-prod-bastion| |--talos-prod-cp-1| |--talos-prod-cp-2| |--talos-prod-cp-3|--@az_eu_west_2a:| |--talos-prod-vault| |--talos-prod-bastion|--@az_eu_west_2b:| |--talos-prod-cp-2|--@az_eu_west_2c:| |--talos-prod-cp-3|--@tina_tinav5:| |--talos-prod-cp-1| |--talos-prod-cp-2| |--talos-prod-cp-3|--@tina_tinav7:| |--talos-prod-bastion|--@bastions:| |--talos-prod-bastion -
Inspecter les hostvars d'un hôte :
Fenêtre de terminal ansible-inventory -i examples/04-full-featured.osc_vm.yml --host talos-prod-bastion
Exclure les VMs non SSH-able (Talos, Bottlerocket…)
Section intitulée « Exclure les VMs non SSH-able (Talos, Bottlerocket…) »Pilier Operational Excellence. Certaines distributions cloud — Talos Linux, Bottlerocket, Flatcar — n'exposent pas SSH. Talos administre ses VMs via l'API talosctl sur le port 50000 avec authentification mTLS ; aucun port 22 ouvert, aucun shell utilisateur. Inclure ces VMs dans un inventaire Ansible produit des erreurs ssh: connection refused à chaque playbook, qui polluent les logs et masquent les vraies pannes.
Le plugin osc_vm propose deux options pour les exclure :
exclude_tags— préféré quand les VMs concernées portent un tag distinctif (os=talos,managed-by=talos, etc.). Liste de couplesKey=ValueouKeyseul (matche toute valeur).exclude_vm_ids— fallback quand le tagging n'est pas en place, liste explicite d'i-XXXXXXXX. À mettre à jour à chaque création/destruction de VM Talos.
plugin: osc_vmprofiles: - defaultfilters: VmStateNames: - runninghostnames: - "tag:Name" - VmId
# Méthode 1 — exclusion par tag (recommandée si tagging discipliné).exclude_tags: - "os=talos" - "managed-by=talos"
# Méthode 2 — fallback par ID explicite.exclude_vm_ids: - "i-da44d982" - "i-404893e4"Validation observée sur le compte de référence (6 VMs Talos en running) :
- Sans exclusion :
6 hôtesretournés → 6 erreurs SSH au prochain playbook. - Avec
exclude_vm_idslistant les 6 IDs :0 hôtesretournés → l'inventaire est exact.
Bonne pratique : poser systématiquement un tag os=<distribution> à la création des VMs (via Terraform ou Packer). L'inventaire devient maintenable même quand les VmIds changent (recréation, scaling) — l'exclude_vm_ids reste un fallback temporaire.
Patterns Well-Architected
Section intitulée « Patterns Well-Architected »Filtrer côté API plutôt que côté Ansible
Section intitulée « Filtrer côté API plutôt que côté Ansible »Pilier Operational Excellence, Cost. Toujours pousser le filtrage vers l'API ReadVms via la clé filters du plugin. Filtrer ensuite dans Ansible avec --limit ou des when: est plus coûteux (transfert réseau + parsing) et expose l'inventaire complet en cache, ce qui complique la traçabilité d'audit. Avec filters: {VmStateNames: [running]}, les VMs stopped et terminated n'apparaissent jamais.
Préfixer toutes les hostvars
Section intitulée « Préfixer toutes les hostvars »Pilier Operational Excellence. Activer hostvars_prefix: "osc_" évite les collisions avec les variables Ansible standard (ansible_host, inventory_hostname, tags, etc.) et rend le code de playbook auto-documenté ({{ osc_subregion }} est sans ambiguïté plus parlant que {{ subregion }}).
Résoudre ansible_host avec entry_role (recommandé)
Section intitulée « Résoudre ansible_host avec entry_role (recommandé) »Pilier Security. Dans un parc avec bastion central, chaque VM doit voir son ansible_host adapté à son rôle :
- Bastion atteint directement →
ansible_host = PublicIp - Toutes les autres VMs atteintes via ProxyJump →
ansible_host = PrivateIps[0]
L'option entry_role du plugin résout ce choix automatiquement, sans compose Jinja conditionnel :
plugin: osc_vm
# Convention : les VMs taguées role=bastion sont les seuls entry points.# Toutes les autres VMs prennent leur PrivateIps[0] (ProxyJump à configurer# en group_vars/role_*.yml ou ~/.ssh/config).entry_role: bastion
compose: ansible_user: "'outscale'" # ansible_host géré par entry_rolePourquoi pas un compose conditionnel :
# Marche, mais hard-code le test métier dans le compose ; pas réutilisable.compose: ansible_host: >- public_ip if vm_tags.role | default('') == 'bastion' else private_ips[0]entry_role met cette logique côté plugin plutôt que côté config. Conventionnel, paramétrable (entry_role: jumpbox, edge, etc.), réutilisable d'un projet à l'autre.
Pattern legacy — compose Jinja sans entry_role
Section intitulée « Pattern legacy — compose Jinja sans entry_role »Si vous utilisez encore osc_vm sans l'option entry_role (rétro-compat), le pattern qui couvre les deux cas (bastion + VMs privées) est de privilégier l'IP privée :
# Avec hostvars_prefix: "osc_"compose: ansible_host: "osc_private_ips[0] | default(osc_public_ip)" ansible_user: "'outscale'"Limite : le bastion lui-même tombe alors sur son IP privée, qui n'est pas joignable depuis le poste opérateur. Pour le contourner, mettre un host_vars/<bastion>.yml au niveau playbook qui force ansible_host: <PublicIp>.
Host bastion-prod HostName 148.253.109.96 User outscale
Host 10.0.* 10.100.* ProxyJump bastion-prod User outscaleCompte EIM scopé pour le pipeline
Section intitulée « Compte EIM scopé pour le pipeline »Pilier Security. Le pipeline qui interroge l'inventaire (CI, AWX, AAP) s'authentifie avec un compte EIM dédié scopé à ReadVms + ReadTags + ReadSubregions — pas de droits d'écriture. Si les credentials sont compromis depuis le runner CI, l'attaquant ne peut que lister l'inventaire, pas le modifier.
Cache jsonfile activé
Section intitulée « Cache jsonfile activé »Pilier Performance Efficiency. Activer cache = True dans ansible.cfg avec cache_plugin = jsonfile réduit drastiquement le nombre d'appels ReadVms lors d'exécutions répétées (par exemple ansible-playbook lancé plusieurs fois sur le même inventaire pendant une session de développement). Avec cache_timeout = 3600, le cache est invalidé toutes les heures — assez fréquent pour rester à jour, assez rare pour ne pas saturer l'API.
Antipatterns à éviter
Section intitulée « Antipatterns à éviter »Hardcoder access_key/secret_key dans l'inventaire. Faille de sécurité massive dès qu'on commit. Toujours passer par les profils OSC ou les variables d'environnement OUTSCALE.
Pas de filtre VmStateNames. L'inventaire remonte alors les VMs stopped, terminated et même les VMs en pending — Ansible essaie de les joindre, échoue, et pollue les logs. Filtrer dès l'API.
tags au lieu de vm_tags. Le nom tags est réservé par Ansible (warning explicite « Found variable using reserved name 'tags' »). Utiliser vm_tags (le plugin l'expose nativement).
Plugin global plutôt que local. Coller osc_vm.py dans /etc/ansible/plugins/inventory/ plutôt que dans le dossier du projet rend la mise à jour difficile et masque la dépendance du projet. Préférer inventory_plugins/ à la racine du projet.
Pas de cache. Sans cache = True, chaque ansible-playbook fait un nouvel appel ReadVms. Sur un parc de 100 VMs avec un pipeline CI qui re-lance plusieurs fois, ça mange du quota API et ralentit l'exécution.
Dépannage
Section intitulée « Dépannage »| Symptôme | Cause probable | Action |
|---|---|---|
Le SDK Python OUTSCALE n'est pas installé | osc-sdk-python non dans le venv Ansible | pipx inject ansible osc-sdk-python |
mapping values are not allowed in this context (DOCUMENTATION) | Chaîne YAML non quotée avec [, ], : | Quoter les chaînes ambiguës ("PrivateIps[0]" plutôt que PrivateIps[0]) |
can only concatenate str (not "NoneType") to str | Profil OSC avec region_name (legacy oapi-cli) silencieusement rejeté par le SDK | Le plugin contourne via _load_profile_credentials — vérifier que la version du plugin est à jour |
Action close does not exists for python sdk | gw.close() interprété comme appel d'API | Utiliser with Gateway(...) as gw: (context manager) au lieu de try/finally + close() |
Found variable using reserved name 'tags' | Hostvar tags (réservé Ansible) exposée | Le plugin expose désormais vm_tags — mettre à jour vos playbooks |
Permission denied (publickey) alors que la clé est correcte | ansible_user: "outscale" dans compose: interprété comme variable (vide) puis fallback sur l'utilisateur courant | Quoter la constante en Jinja : ansible_user: "'outscale'" |
À retenir
Section intitulée « À retenir »osc-sdk-pythoncomme client officiel — gérer la divergenceregion_name/regioncôté plugin, lire manuellement~/.osc/config.json.with Gateway(...) as gw:comme context manager — évitergw.close()qui serait un appel API.- DOCUMENTATION YAML — quoter les chaînes contenant
[,],:pour éviter les ambiguïtés de parsing. hostvars_prefix: "osc_"systématique — évite les collisions Ansible.vm_tagsplutôt quetags(réservé Ansible).with Gateway(**creds)oùcredsvient de_load_profile_credentials(profile)qui normaliseregion_name→regionethttps: true→protocol: "https".- Cache
jsonfileactivé — coupe les appels API répétés. - Compte EIM scopé pour le pipeline —
ReadVms+ReadTagssuffisent. compose:est du Jinja — pour assigner une string littérale (ex.ansible_user), il faut quoter en interne :"'outscale'"(double-quote YAML, single-quote Jinja).entry_role: bastionrésout automatiquementansible_hostselon le rôle — IP publique pour le bastion, IP privée pour les autres. Évite un compose Jinja conditionnel.