Aller au contenu
Cloud medium

Inventaire dynamique Ansible pour OUTSCALE — plugin Python complet

26 min de lecture

logo 3ds outscale

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

  • Concevoir un plugin d'inventaire Ansible conforme à la grammaire Ansible (DOCUMENTATION, options, héritage BaseInventoryPlugin).
  • Authentifier via osc-sdk-python en 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 FiltersVm de la spec OAPI.
  • Résoudre inventory_hostname par 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.
  • 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écifiquesLifecycles: spot, NicSubregionNames, BootModes (uefi/legacy), TpmEnabled ne sont pas exposés.
  • Pas de format TINA natifvm_type est exposé sous le mapping AWS-style (t2.small), pas en TINA (tinav5.c2r4p2), ce qui complique les keyed_groups par 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.

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.

  • 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
[defaults]
inventory_plugins = ./inventory_plugins
host_key_checking = False
stdout_callback = yaml
[inventory]
enable_plugins = osc_vm, host_list, script, auto, yaml, ini, toml
cache = True
cache_plugin = jsonfile
cache_connection = ./.osc_vm_cache
cache_timeout = 3600

osc_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 bloc YAML décrit toutes les options du plugin pour la documentation Ansible automatique.

DOCUMENTATION = r"""
name: osc_vm
plugin_type: inventory
short_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_cache
options:
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
"""
from ansible.errors import AnsibleError
from ansible.plugins.inventory import BaseInventoryPlugin, Constructable, Cacheable
from ansible.utils.display import Display
try:
from osc_sdk_python import Gateway
HAS_OSC_SDK = True
OSC_SDK_IMPORT_ERROR = None
except 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)

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 :

@staticmethod
def _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 creds
@staticmethod
def _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", []))
@staticmethod
def _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 "")

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 non tags réservé par Ansible) — dict des tags pour usage en Jinja2.
  • private_ips — liste flat des IPs privées multi-NIC.
@staticmethod
def _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 hostvars
examples/01-basic.osc_vm.yml
plugin: osc_vm
profiles:
- default
filters:
VmStateNames:
- running
examples/02-filters.osc_vm.yml
plugin: osc_vm
profiles:
- default
regions:
- eu-west-2
filters:
VmStateNames:
- running
SubregionNames:
- eu-west-2a
hostnames:
- "tag:Name"
- PublicIp
- VmId
examples/03-keyed-groups.osc_vm.yml
plugin: osc_vm
profiles:
- default
filters:
VmStateNames:
- running
hostnames:
- "tag:Name"
- VmId
hostvars_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 »
examples/04-full-featured.osc_vm.yml
plugin: osc_vm
profiles:
- default
regions:
- eu-west-2
filters:
VmStateNames:
- running
hostnames:
- "tag:Name"
- VmId
hostvars_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'"
  1. Préparer l'environnement :

    Fenêtre de terminal
    pipx inject ansible osc-sdk-python # si Ansible installé via pipx
    direnv allow # charge OSC_PROFILE
  2. Lancer un inventaire basique :

    Fenêtre de terminal
    ansible-inventory -i examples/01-basic.osc_vm.yml --list | head -20
  3. Vérifier les groupes générés :

    Fenêtre de terminal
    ansible-inventory -i examples/04-full-featured.osc_vm.yml --graph

    Sortie 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
  4. 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 couples Key=Value ou Key seul (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.
examples/05-exclude-talos.osc_vm.yml
plugin: osc_vm
profiles:
- default
filters:
VmStateNames:
- running
hostnames:
- "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ôtes retournés → 6 erreurs SSH au prochain playbook.
  • Avec exclude_vm_ids listant les 6 IDs : 0 hôtes retourné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.

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.

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_role

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

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

~/.ssh/config
Host bastion-prod
HostName 148.253.109.96
User outscale
Host 10.0.* 10.100.*
ProxyJump bastion-prod
User outscale

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.

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.

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.

SymptômeCause probableAction
Le SDK Python OUTSCALE n'est pas installéosc-sdk-python non dans le venv Ansiblepipx 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 strProfil OSC avec region_name (legacy oapi-cli) silencieusement rejeté par le SDKLe plugin contourne via _load_profile_credentials — vérifier que la version du plugin est à jour
Action close does not exists for python sdkgw.close() interprété comme appel d'APIUtiliser with Gateway(...) as gw: (context manager) au lieu de try/finally + close()
Found variable using reserved name 'tags'Hostvar tags (réservé Ansible) exposéeLe plugin expose désormais vm_tags — mettre à jour vos playbooks
Permission denied (publickey) alors que la clé est correcteansible_user: "outscale" dans compose: interprété comme variable (vide) puis fallback sur l'utilisateur courantQuoter la constante en Jinja : ansible_user: "'outscale'"
  • osc-sdk-python comme client officiel — gérer la divergence region_name / region côté plugin, lire manuellement ~/.osc/config.json.
  • with Gateway(...) as gw: comme context manager — éviter gw.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_tags plutôt que tags (réservé Ansible).
  • with Gateway(**creds)creds vient de _load_profile_credentials(profile) qui normalise region_nameregion et https: trueprotocol: "https".
  • Cache jsonfile activé — coupe les appels API répétés.
  • Compte EIM scopé pour le pipeline — ReadVms + ReadTags suffisent.
  • 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: bastion résout automatiquement ansible_host selon le rôle — IP publique pour le bastion, IP privée pour les autres. Évite un compose Jinja conditionnel.

Ce site vous est utile ?

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

Je maintiens +700 guides gratuits, sans pub ni tracing. Aujourd'hui, ce site ne couvre même pas mes frais d'hébergement, d'électricité, de matériel, de logiciels, mais surtout de cafés.

Un soutien régulier, même symbolique, m'aide à garder ces ressources gratuites et à continuer de produire des guides de qualité. Merci pour votre appui.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn