Aller au contenu
Infrastructure as Code medium

Action plugins Ansible : exécution côté control node, cas d'usage et structure

14 min de lecture

Logo Ansible

Un action plugin Ansible s’exécute côté control node, pas sur la cible. C’est la différence fondamentale avec un module Python classique : un module est transféré et exécuté sur le managed node ; un action plugin tourne localement sur le poste qui lance Ansible, avec accès aux variables, aux facts collectés, et au moteur de template. C’est l’outil idéal pour orchestrer plusieurs modules, pré-calculer des données complexes avant de les pousser, ou fusionner des fichiers de configuration sans impliquer la cible. Cette page couvre les cas d’usage légitimes, la structure d’un action plugin, et la frontière avec les modules classiques.

  • Distinguer un module classique (exécuté sur la cible) d’un action plugin (exécuté sur le control node).
  • Identifier les 3 cas d’usage légitimes d’un action plugin et ceux où un filtre / module suffit.
  • Implémenter un action plugin minimal avec ActionBase et _VALID_ARGS.
  • Déléguer à un module classique depuis l’action plugin (pattern « action wrapper »).
  • Tester un action plugin avec ansible-test sanity --docker.

Module vs action plugin — où s’exécute le code ?

Section intitulée « Module vs action plugin — où s’exécute le code ? »

Pour clarifier la différence : quand vous écrivez - ansible.builtin.copy: ..., ce qui se passe vraiment dépend du type de plugin :

┌─────────────────┐ ┌──────────────────┐
│ Control node │ │ Managed node │
│ (votre poste) │ ─── SSH ────► │ (web1.lab) │
└─────────────────┘ └──────────────────┘
Module classique :
1. Le control node prépare les arguments + le module Python
2. Transfert via SSH (ou Podman si EE)
3. Exécution Python SUR LA CIBLE
4. Retour du résultat
Action plugin :
1. Le code tourne SUR LE CONTROL NODE (votre poste)
2. Accès à task_vars, facts collectés, secrets déchiffrés
3. Peut appeler des modules classiques DE-PUIS le control node
4. Retour direct

Conséquences pratiques :

AspectModuleAction plugin
Prérequis Python sur la cibleOuiNon (sauf si délègue à un module)
Accès à ansible_facts.*Oui (transmis par Ansible)Oui (directement via task_vars)
Accès aux variables VaultOui (déchiffrées avant transfert)Oui (en mémoire)
Performance sur grand parcLimitée par SSH × N hôtesLocale, peut paralléliser librement
Accès aux fichiers du projet (templates, files)NonOui

Vous avez N fichiers de configuration partiels qu’il faut fusionner et templater avant de pousser sur la cible :

# Sans action plugin : 3 tâches successives
- ansible.builtin.template:
src: base.conf.j2
dest: /tmp/part1.conf
- ansible.builtin.template:
src: prod.conf.j2
dest: /tmp/part2.conf
- ansible.builtin.assemble: # module assemble côté cible
src: /tmp/
dest: /etc/myapp/full.conf
# Avec action plugin : 1 tâche, fusion sur control node
- mycollection.merge_configs:
sources:
- templates/base.conf.j2
- templates/prod.conf.j2
dest: /etc/myapp/full.conf

Le pré-traitement côté control node évite 3 transferts SSH et la création de fichiers temporaires sur la cible.

Vous voulez un module mycollection.copy_with_audit qui délègue à ansible.builtin.copy mais ajoute un log d’audit dans une BDD locale au control node :

class ActionModule(ActionBase):
_VALID_ARGS = frozenset(('src', 'dest', 'audit_user'))
def run(self, tmp=None, task_vars=None):
result = super().run(tmp, task_vars)
del tmp
# Déléguer à ansible.builtin.copy
copy_result = self._execute_module(
module_name='ansible.builtin.copy',
module_args={
'src': self._task.args.get('src'),
'dest': self._task.args.get('dest'),
},
task_vars=task_vars,
)
# Ajouter notre logique : auditer en local
if copy_result.get('changed'):
self._log_audit(
user=self._task.args.get('audit_user'),
target=self._task.args.get('dest'),
host=task_vars['inventory_hostname'],
)
result.update(copy_result)
return result

L’utilisateur du plugin écrit mycollection.copy_with_audit: exactement comme copy:, mais bénéficie de l’audit transparent.

Un action plugin peut appeler plusieurs modules dans un ordre conditionnel — utile pour des opérations atomiques où le rollback est intégré :

def run(self, tmp=None, task_vars=None):
result = super().run(tmp, task_vars)
# 1. Snapshot
snap = self._execute_module(module_name='community.general.snapshot', ...)
if snap.get('failed'):
result['failed'] = True
return result
# 2. Migration
migrate = self._execute_module(module_name='ansible.builtin.command', ...)
if migrate.get('failed'):
# Rollback automatique
self._execute_module(module_name='community.general.snapshot_restore', ...)
result['failed'] = True
result['msg'] = "Migration échouée, rollback appliqué"
return result
result['changed'] = True
return result

Pattern utile pour les migrations BDD ou les opérations critiques qui demandent un rollback intégré.

Vous pensez avoir besoin…Préférer
Manipuler une string/liste/dictFiltre Jinja custom (plus léger)
Faire une boucle complexeloop: + selectattr + map
Capturer une valeur et la réutiliserset_fact: + register:
Conditionner l’exécution selon un factwhen:

Un action plugin ajoute de la complexité (Python, packaging dans collection, tests). Le réserver aux vraies orchestrations multi-étapes ou aux cas où le pré-traitement local apporte un gain net.

mycollection/
├── galaxy.yml
├── plugins/
│ ├── action/
│ │ └── myaction.py # le code Python du plugin
│ └── modules/
│ └── myaction.py # facultatif — un module compagnon
├── meta/
│ └── runtime.yml
└── README.md

Pattern courant : un fichier dans plugins/action/<nom>.py (logique côté control node) et un fichier dans plugins/modules/<nom>.py (module compagnon avec DOCUMENTATION, EXAMPLES, RETURN). Quand l’utilisateur appelle mycollection.<nom>:, Ansible exécute le action/ ; le modules/ sert pour la doc et le fallback si _execute_module est utilisé.

#!/usr/bin/python
from ansible.errors import AnsibleActionFail
from ansible.plugins.action import ActionBase
from ansible.utils.display import Display
display = Display()
class ActionModule(ActionBase):
"""Action plugin qui fusionne plusieurs templates en un seul fichier."""
TRANSFERS_FILES = True
_VALID_ARGS = frozenset(('sources', 'dest', 'mode', 'owner', 'group'))
def run(self, tmp=None, task_vars=None):
result = super().run(tmp, task_vars)
del tmp
# 1. Validation des arguments
sources = self._task.args.get('sources')
dest = self._task.args.get('dest')
if not sources or not dest:
raise AnsibleActionFail("'sources' et 'dest' sont obligatoires")
# 2. Templating local de chaque source
merged = []
for src in sources:
content = self._loader.get_real_file(src)
templated = self._templar.template(open(content).read())
merged.append(templated)
# 3. Écriture d'un fichier temporaire local
merged_file = '/tmp/merged_{}.conf'.format(task_vars['inventory_hostname'])
with open(merged_file, 'w') as f:
f.write('\n'.join(merged))
# 4. Délégation à copy: pour transférer sur la cible
copy_args = {
'src': merged_file,
'dest': dest,
}
for opt in ('mode', 'owner', 'group'):
if self._task.args.get(opt):
copy_args[opt] = self._task.args[opt]
copy_result = self._execute_module(
module_name='ansible.builtin.copy',
module_args=copy_args,
task_vars=task_vars,
)
# 5. Nettoyage du fichier temporaire local
import os
os.remove(merged_file)
result.update(copy_result)
return result

Points-clés :

  • _VALID_ARGS valide les arguments fournis — typo détecté tôt.
  • AnsibleActionFail pour échouer proprement avec un message clair (préféré à result['failed'] = True).
  • self._templar.template() pour interpoler les variables Jinja dans une string.
  • self._execute_module pour déléguer à un module classique depuis l’action plugin.
  • Nettoyage explicite du fichier temporaire local (sinon il s’accumule).

Comme pour les modules, ansible-test sanity --docker default valide :

  • La conformité PEP8.
  • Les imports.
  • L’absence d’erreurs lors du chargement.
Fenêtre de terminal
cd mycollection/
ansible-test sanity --docker default
ansible-test units --docker default plugins/action/

Pour des tests fonctionnels, écrire un playbook de test dans tests/ qui appelle l’action sur localhost.

  • Action plugin = code côté control node, pas sur la cible. Différent du module classique qui s’exécute distant.
  • 3 cas légitimes : pré-traitement avant déploiement, wrapper avec auto-magic, orchestration multi-modules.
  • Pas de surcouche sur ce qu’un filtre Jinja, un loop: ou un set_fact: peut faire.
  • _VALID_ARGS (Ansible 2.13+) + AnsibleActionFail pour validation et erreurs propres.
  • self._execute_module pour déléguer à un module classique depuis le plugin.
  • Packager dans une collection dès le départ — plugins/action/ est le dossier standard.

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