Services Blog English

Tutoriel de développement de plugins d'action Ansible personnalisés

| par jpic | python dev ansible devops

Quand votre logique d’orchestration devient compliquée, vous pouvez vous retrouver avec beaucoup de code YAML difficilement lisible, et ce n’est pas très agréable à maintenir, par exemple quand vous mutez des variables avec des combinaisons de filtres de templates, set_fact et autres. La solution est de passer le code logique de YAML à Python, qui est un vrai langage de programmation.

Comme vous le savez peut-être : les modules Ansible sont téléversés sur l’hôte cible, exécutés là-bas, puis leur résultat est renvoyé à Ansible.

Ici, nous présentons les plugins d’action Ansible. Ils s’exécutent sur l’hôte de contrôle, peuvent afficher des données en direct, accéder à toutes les variables de l’inventaire et exécuter n’importe quel nombre de modules, par exemple.

Démarrage

Créez un nouveau rôle avec un répertoire action_plugins :

ansible-galaxy role init example
mkdir example/action_plugins

Créez un script example/action_plugins/example.py :

from ansible.plugins.action import ActionBase

class ActionModule(ActionBase):
    def run(self, tmp=None, task_vars=None):
        result = dict(changed=False)
        return result

Appelez le plugin depuis votre script example/tasks/main.yml :

---
- name: Example action
  example:

Et un playbook :

- hosts: localhost
  connection: local
  roles:
  - example

Lancez-le avec :

ansible-playbook test.yml

Affichage

Vous ne pouvez pas utiliser la fonction Python print() dans les plugins Ansible. À la place, vous devez utiliser l’objet Display fourni, disponible dans l’objet action sous self._display.

L’équivalent de print() est self._display.display(), et il affichera toujours. Mais d’autres méthodes sont disponibles, comme self._display.verbose(), self._display.warning(), self._display.error().

La méthode verbose() permet de passer un niveau de verbosité. Si Ansible s’exécute avec au plus ce niveau de verbosité, alors le message est affiché ; sinon il ne l’est pas.

Par exemple :

from ansible.plugins.action import ActionBase


class ActionModule(ActionBase):
    def run(self, tmp=None, task_vars=None):
        result = dict(changed=False)
        self._display.display('Always printed')
        self._display.verbose('Verbose message', caplevel=0)
        self._display.verbose('Very verbose message', caplevel=1)
        return result

Cela produira différentes sorties selon la verbosité d’exécution d’Ansible.

Sans aucun flag verbose, seul le message display() est affiché :

$ ansible-playbook test.yml
PLAY [localhost] *******************************************************************************************

TASK [example : Example action] ****************************************************************************
Always printed
ok: [localhost]

PLAY RECAP *************************************************************************************************
localhost                  : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Avec un -v, seuls display() et verbose(..., caplevel=0) sont affichés :

$ ansible-playbook test.yml -v
PLAY [localhost] *******************************************************************************************

TASK [example : Example action] ****************************************************************************
Always printed
Verbose message
ok: [localhost]

PLAY RECAP *************************************************************************************************
localhost                  : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Et avec -vv, tous nos messages sont affichés :

$ ansible-playbook test.yml -vv
PLAY [localhost] *******************************************************************************************

TASK [example : Example action] ****************************************************************************
task path: /home/jpic/src/action_plugin_example/example/tasks/main.yml:2
Always printed
Verbose message
Very verbose message
ok: [localhost] => {"changed": false}

PLAY RECAP *************************************************************************************************
localhost                  : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Notez que caplevel doit être entre 0 et 5, et que des noms de méthodes raccourcis sont disponibles. Le code ci-dessus aurait pu être écrit ainsi :

class ActionModule(ActionBase):
    def run(self, tmp=None, task_vars=None):
        result = dict(changed=False)
        self._display.display('Always printed')
        self._display.v('Verbose message')
        self._display.vv('Very verbose message')
        return result

Notez que les méthodes d’affichage verbose acceptent un argument hôte, donc :

self._display.vv('Very verbose message', 'some-host')

S’affichera comme :

<some-host> Very verbose message

D’autres méthodes d’affichage peuvent être utiles :

  • self._display.warning(message) : afficher un avertissement
  • self._display.error(message) : afficher une erreur
  • self._display.deprecated(message) : afficher un message dans un avertissement de dépréciation, pour conseiller à l’utilisateur de mettre quelque chose à niveau

Exemple :

self._display.warning('Example warning')
self._display.error('Example error')
self._display.deprecated('Example deprecation')

Sortie :

[ERROR]: Example error
[WARNING]: Example warning
[WARNING]: Deprecation warnings can be disabled by setting `deprecation_warnings=False` in ansible.cfg.
[DEPRECATION WARNING]: Example deprecation. This feature will be removed in the future.

Saisie utilisateur

Comme nous ne pouvons pas utiliser print() directement pour afficher des choses, nous ne pouvons pas non plus utiliser input() pour lire. À la place, nous devons utiliser la méthode prompt_until de l’objet Display. Exemple :

from ansible.module_utils._text import to_text

class ActionModule(ActionBase):
    def run(self, tmp=None, task_vars=None):
        your_input = to_text(self._display.prompt_until(
            'Type your input here:',
        ))
        self._display.display('You typed: ' + your_input)
        return dict(changed=False)

Sortie :

TASK [example : Example action] ****************************************************************************
Type your input here: My message
You typed: My message

Variables

Dans task_vars, vous obtenez toutes les variables qui seraient disponibles en YAML, comme les facts. Exemple :

class ActionModule(ActionBase):
    def run(self, tmp=None, task_vars=None):
        self._display.display(f'Running for {task_vars["inventory_hostname"]}')
        self._display.display(f'BTW I use {task_vars["ansible_os_family"]}')

Affichera :

TASK [example : Example action] ********************************************************
Running for localhost
BTW I use Archlinux

Utilisez le débogueur, décrit plus loin, pour découvrir toutes les clés disponibles dans task_vars. Mais notez que vous pouvez accéder à tout, y compris hostvars, qui contient les variables de chaque hôte. Ainsi, vous avez déjà la main sur le large éventail de variables que le runtime Ansible a à offrir, ce qui est déjà beaucoup plus que ce à quoi vous êtes probablement habitué avec les modules.

Paramètres

Comme les modules, les plugins d’action Ansible peuvent accepter des arguments. Modifiez votre example/tasks/main.yml ainsi :

- name: Example action
  example:
    foo: bar
    some:
      nested: variable
    alist:
    - item

Les variables seront disponibles dans self._task.args. Dumppez-les ainsi :

class ActionModule(ActionBase):
    def run(self, tmp=None, task_vars=None):
        self._display.display(repr(self._task.args))
        return dict(changed=False)

Sortie :

TASK [example : Example action] ********************************************************
{'alist': ['item'], 'foo': 'bar', 'some': {'nested': 'variable'}}
ok: [localhost]

Débogage

Avant Ansible 2.19, vous pouviez simplement utiliser madbg tel quel. Installez-le avec :

pip install madbg

Et dans votre module, ajoutez l’incantation import madbg; madbg.set_trace() :

from ansible.plugins.action import ActionBase


class ActionModule(ActionBase):
    def run(self, tmp=None, task_vars=None):
        import madbg; madbg.set_trace()
        return dict(changed=False)

Relancez votre playbook :

$ ansible-playbook test.yml
PLAY [localhost] ***********************************************************************

TASK [Gathering Facts] *****************************************************************
ok: [localhost]

TASK [example : Example action] ********************************************************
Waiting for debugger client on 127.0.0.1:3513

Dans un autre terminal, lancez madbg connect :

$ madbg connect
> /home/jpic/src/action_plugin_example/example/action_plugins/example.py(9)run()
      7 class ActionModule(ActionBase):
      8     def run(self, tmp=None, task_vars=None):
----> 9         import madbg; madbg.set_trace()
     10         result = dict(changed=False)
     11         self._display.display(cli2.render([*task_vars.keys()]))

ipdb>

Et vous êtes maintenant dans un débogueur IPython. Si vous ne savez pas l’utiliser, consultez ce tutoriel Python-fu sur le débogage pdb.

Débogage avec Ansible >= 2.19

Cependant, quand on essaie d’utiliser madbg ou pdb avec ansible-core >= 2.19, ce qui précède produira quelque chose comme :

[ERROR]: Task failed: I/O operation on closed file
Origin: /home/jpic/src/action_plugin_example/example/tasks/main.yml:2:3

1 ---
2 - name: Example action
    ^ column 3

fatal: [localhost]: FAILED! => {"changed": false, "msg": "Task failed: I/O operation on closed file"}

Et une traceback pour la même erreur dans madbg connect. Cela a été introduit par le patch Not inherit from stdio, qui fait que le worker se détache de stdin.

La solution consiste à shunter la méthode de détachement. D’abord, trouvez où Ansible est installé :

$ python -c 'import ansible; print(ansible.__path__)'
['/home/jpic/.local/lib/python3.13/site-packages/ansible']

Trouvez-y la fonction _detach avec grep par exemple :

$ grep -r def._detach /home/jpic/.local/lib/python3.13/site-packages/ansible
/home/jpic/.local/lib/python3.13/site-packages/ansible/executor/process/worker.py:    def _detach(self) -> None:

Et commentez la ligne où il fait sys.stdin.close(). Ensuite, madbg connect fonctionnera à nouveau.

Modules

Maintenant que nous avons couvert les bases, passons aux choses vraiment amusantes : remplacer YAML par Python pour gérer une complexité plus grande, ce qui signifie que nous allons appeler des modules comme nous le faisons en YAML, mais en Python.

Dans cet exemple, nous allons lancer les modules Ansible “file” et “copy”, équivalents à ce YAML :

- copy:
    src: /tmp/src
    dest: /tmp/dest

En Python :

class ActionModule(ActionBase):
    def run(self, tmp=None, task_vars=None):
        # make sure we have something in /tmp/src
        with open('/tmp/src', 'w') as f:
            f.write('foo')

        # actually execute the copy module
        copy_result = self._execute_module(
            'copy',
            module_args=dict(
                src='/tmp/src',
                dest='/tmp/dest',
            ),
            task_vars=task_vars,
        )

        # we could create our own result dict here too
        return copy_result

Résultat :

TASK [example : example] ********************************************************************************
ok: [localhost] => changed=false
  checksum: 0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33
  dest: /tmp/dest
  gid: 1000
  group: jpic
  md5sum: acbd18db4cc2f85cedef654fccc4a4d8
  mode: '0644'
  owner: jpic
  size: 3
  src: /tmp/src
  state: file
  uid: 1000

Actions

Le module shell est en réalité un plugin d’action, comme on peut le voir dans le code source d’Ansible, parce qu’il se trouve dans le répertoire plugins/action.

Dans cet exemple, nous allons lancer ce YAML simple :

- shell: date

En Python :

class ActionModule(ActionBase):
    def run(self, tmp=None, task_vars=None):
        # Create a new Ansible Task in Python
        new_task = self._task.copy()
        # So that we can set our args in Python
        new_task.args = dict(_raw_params='date')
        # And load the shell action
        shell_action = self._shared_loader_obj.action_loader.get(
            'ansible.builtin.shell',
            task=new_task,
            connection=self._connection,
            play_context=self._play_context,
            loader=self._loader,
            templar=self._templar,
            shared_loader_obj=self._shared_loader_obj,
        )
        result = shell_action.run(task_vars=task_vars.copy())
        return result
TASK [example : Example action] ********************************************************
changed: [localhost] => changed=true
  cmd: date
  delta: '0:00:00.007122'
  end: '2025-11-05 15:15:14.996482'
  msg: ''
  rc: 0
  start: '2025-11-05 15:15:14.989360'
  stderr: ''
  stderr_lines: <omitted>
  stdout: Wed Nov  5 15:15:14 CET 2025
  stdout_lines: <omitted>

Framework de plugins d’action cansible

Tirez davantage des plugins d’action avec notre framework de plugins d’action Ansible

Ils nous font confiance

Contact

logo