Aller au contenu
Sécurité medium

Testinfra : tester votre infrastructure avec Python

26 min de lecture

logo testinfra

Ce guide vous permet de valider automatiquement l’état de vos serveurs en écrivant des tests Python. Vous apprendrez à installer Testinfra, écrire des tests pour les fichiers, services, paquets et configurations réseau, puis exécuter ces tests localement ou sur des serveurs distants. Testinfra résout un problème fondamental du DevOps : comment s’assurer que votre infrastructure correspond réellement à ce que vous avez défini dans vos scripts de configuration.

  • Comprendre le rôle de Testinfra dans l’Infrastructure as Code
  • Installer Testinfra et écrire vos premiers tests
  • Utiliser les modules File, Service, Package, User, Interface, Socket
  • Tester des serveurs distants via SSH, Docker, Ansible ou Kubernetes
  • Paramétrer vos tests pour éviter la duplication
  • Intégrer Testinfra dans vos pipelines CI/CD

Prérequis : Python 3.9+ et pip installés. Connaissances de base en ligne de commande Linux.

Testinfra est une bibliothèque Python qui permet d’écrire des tests unitaires pour valider l’état réel de vos serveurs. Imaginez que vous avez un playbook Ansible qui installe nginx et configure un pare-feu. Comment vérifier que tout s’est bien passé ? Vous pourriez vous connecter en SSH et exécuter quelques commandes manuellement, mais c’est fastidieux et non reproductible. Testinfra automatise cette vérification.

L’outil s’intègre à pytest, le framework de test le plus populaire en Python. Si vous connaissez pytest, vous savez déjà comment structurer vos tests Testinfra. Cette intégration apporte des fonctionnalités puissantes comme la paramétrisation, les fixtures et les rapports détaillés.

ApprocheSans TestinfraAvec Testinfra
VérificationConnexion manuelle, commandes ad hocTests automatisés, reproductibles
DocumentationImplicite, dans la tête de l’adminExplicite, dans le code des tests
RégressionDécouverte en productionDétection en CI/CD avant déploiement
ConformitéAudit manuel périodiqueValidation continue

Testinfra est l’équivalent Python de Serverspec (Ruby). Il est maintenu par l’équipe pytest-dev et utilisé par de nombreuses entreprises pour valider leurs déploiements automatisés.

Quand vous déployez un serveur avec Ansible, Puppet ou un script shell, vous faites confiance à vos outils pour appliquer la configuration correctement. Mais plusieurs situations peuvent créer un écart entre l’état désiré et l’état réel.

La dérive de configuration se produit quand un administrateur modifie manuellement un serveur “juste pour tester”, puis oublie de documenter le changement. Des semaines plus tard, le playbook Ansible est relancé et écrase ces modifications, ou pire, le serveur se comporte différemment des autres. Testinfra détecte ces écarts.

Les erreurs silencieuses surviennent quand un outil de configuration termine sans erreur mais n’applique pas tout correctement. Par exemple, un service peut être installé mais pas démarré, ou un fichier peut exister avec de mauvaises permissions. Testinfra vérifie chaque aspect critique.

La régression de déploiement apparaît quand une mise à jour de dépendance ou un changement d’image de base casse une fonctionnalité. En exécutant les tests Testinfra dans votre pipeline CI/CD, vous détectez ces problèmes avant qu’ils n’atteignent la production.

Testinfra est distribué comme un plugin pytest. L’installation via pip est la méthode recommandée.

  1. Installer Testinfra avec pip

    Fenêtre de terminal
    pip install pytest-testinfra

    Cette commande installe Testinfra et ses dépendances, y compris pytest si vous ne l’avez pas déjà.

  2. Vérifier l’installation

    Fenêtre de terminal
    pip show pytest-testinfra

    Résultat attendu :

    Name: pytest-testinfra
    Version: 10.2.2
  3. Vérifier que pytest reconnaît le plugin

    Fenêtre de terminal
    pytest --version

    La sortie doit mentionner testinfra dans la liste des plugins.

Si vous prévoyez de tester des serveurs distants, installez les dépendances correspondantes :

Fenêtre de terminal
# Pour SSH avec Paramiko (recommandé)
pip install pytest-testinfra[paramiko]
# Pour Ansible
pip install pytest-testinfra[ansible]
# Pour Salt
pip install pytest-testinfra[salt]
# Tous les backends
pip install pytest-testinfra[paramiko,ansible,salt]

Les tests Testinfra sont des fonctions Python qui reçoivent un objet host en paramètre. Cet objet représente le serveur à tester et donne accès à tous les modules de Testinfra.

Créez un fichier test_system.py :

"""Tests de base sur le système."""
def test_passwd_file(host):
"""Vérifie que le fichier /etc/passwd existe et est bien configuré."""
passwd = host.file("/etc/passwd")
assert passwd.exists
assert passwd.is_file
assert passwd.user == "root"
assert passwd.group == "root"
assert passwd.mode == 0o644

Ce test vérifie cinq propriétés du fichier /etc/passwd : son existence, qu’il s’agit bien d’un fichier (pas un répertoire), son propriétaire, son groupe et ses permissions. Si l’une de ces assertions échoue, pytest affiche un message d’erreur descriptif.

Fenêtre de terminal
pytest test_system.py -v

Résultat attendu :

======================== test session starts =========================
platform linux -- Python 3.12.3, pytest-9.0.2, pluggy-1.6.0
plugins: testinfra-10.2.2
collected 1 item
test_system.py::test_passwd_file[local] PASSED [100%]
========================= 1 passed in 0.05s ==========================

Le suffixe [local] indique que le test a été exécuté sur la machine locale, sans connexion distante.

Testinfra fournit une vingtaine de modules pour interroger différents aspects d’un système. Chaque module est accessible via l’objet host passé à vos fonctions de test.

Le module File est le plus utilisé. Il permet de vérifier l’existence, le contenu, les permissions et les métadonnées des fichiers.

def test_nginx_config(host):
"""Vérifie la configuration nginx."""
config = host.file("/etc/nginx/nginx.conf")
# Existence et type
assert config.exists
assert config.is_file
# Propriétaire et permissions
assert config.user == "root"
assert config.group == "root"
assert config.mode == 0o644
# Contenu
assert config.contains("worker_processes")
# Si c'est un lien symbolique
link = host.file("/etc/alternatives/editor")
if link.is_symlink:
print(f"Pointe vers : {link.linked_to}")

Propriétés disponibles sur un fichier :

PropriétéDescriptionExemple
existsLe fichier existeTrue
is_fileC’est un fichier régulierTrue
is_directoryC’est un répertoireFalse
is_symlinkC’est un lien symboliqueFalse
is_executablePermission d’exécutionFalse
userPropriétaire (nom)"root"
uidPropriétaire (ID)0
groupGroupe (nom)"root"
gidGroupe (ID)0
modePermissions (octal)0o644
sizeTaille en octets1024
contentContenu (bytes)b"..."
content_stringContenu (string)"..."
md5sumHash MD5"d41d..."
sha256sumHash SHA256"e3b0..."
mtimeDate de modificationdatetime(...)
linked_toCible si symlink"/usr/bin/vim"

Le module Service vérifie l’état des services système. Il détecte automatiquement le système d’init (systemd, SysV, OpenRC).

def test_nginx_service(host):
"""Vérifie que nginx est démarré et activé."""
nginx = host.service("nginx")
assert nginx.exists, "Le service nginx n'existe pas"
assert nginx.is_running, "nginx n'est pas en cours d'exécution"
assert nginx.is_enabled, "nginx ne démarre pas au boot"

Sur les systèmes avec systemd, des propriétés supplémentaires sont disponibles :

def test_systemd_service(host):
"""Vérifie les propriétés systemd d'un service."""
sshd = host.service("sshd")
# Disponible uniquement avec systemd
assert sshd.is_valid, "Le fichier unit est invalide"
assert not sshd.is_masked, "Le service est masqué"
# Récupérer toutes les propriétés systemd
props = sshd.systemd_properties
fragment_path = props.get("FragmentPath")

Le module Package vérifie si un paquet est installé et sa version. Il supporte apt, dnf, yum, apk, pacman, brew et d’autres gestionnaires.

def test_packages_installed(host):
"""Vérifie que les paquets requis sont installés."""
nginx = host.package("nginx")
assert nginx.is_installed
# Vérifier la version
python = host.package("python3")
assert python.is_installed
assert python.version.startswith("3.")

Le module détecte automatiquement le gestionnaire de paquets approprié en fonction de la distribution.

Ces modules vérifient l’existence et les propriétés des utilisateurs et groupes système.

def test_application_user(host):
"""Vérifie que l'utilisateur applicatif existe."""
app_user = host.user("www-data")
assert app_user.exists
assert app_user.uid >= 1000 or app_user.uid < 100 # user système ou applicatif
assert app_user.group == "www-data"
assert app_user.home == "/var/www"
assert app_user.shell in ["/usr/sbin/nologin", "/bin/false"]
# Groupes secondaires
assert "docker" in app_user.groups or True # optionnel
def test_docker_group(host):
"""Vérifie le groupe docker."""
docker_group = host.group("docker")
if docker_group.exists:
# Liste des membres du groupe
members = docker_group.members
assert len(members) >= 0

Le module Interface inspecte les interfaces réseau.

def test_network_interfaces(host):
"""Vérifie la configuration réseau."""
# Interface loopback
lo = host.interface("lo")
assert lo.exists
assert "127.0.0.1" in lo.addresses
# Lister toutes les interfaces
interfaces = host.interface.names()
assert "lo" in interfaces
# Interface par défaut (celle utilisée pour la route par défaut)
default = host.interface.default()
if default:
print(f"Interface par défaut : {default}")
routes = default.routes()

Le module Socket vérifie les sockets en écoute (TCP, UDP, Unix).

def test_listening_ports(host):
"""Vérifie les ports en écoute."""
# SSH sur le port 22
ssh = host.socket("tcp://22")
assert ssh.is_listening
# Nginx sur le port 80 (toutes les IPs)
http = host.socket("tcp://0.0.0.0:80")
assert http.is_listening
# Socket Unix
docker = host.socket("unix:///var/run/docker.sock")
if docker.is_listening:
clients = docker.clients
print(f"Clients connectés : {len(clients)}")
# Lister tous les sockets en écoute
all_sockets = host.socket.get_listening_sockets()
print(f"Sockets en écoute : {all_sockets}")

Ce module vérifie les points de montage.

def test_filesystems(host):
"""Vérifie les systèmes de fichiers montés."""
root = host.mount_point("/")
assert root.exists
assert root.filesystem in ["ext4", "xfs", "btrfs"]
assert "rw" in root.options # lecture-écriture
# Lister tous les points de montage
mounts = host.mount_point.get_mountpoints()
for mount in mounts:
if mount.filesystem not in ["proc", "sysfs", "tmpfs"]:
print(f"{mount.device} -> {mount.path}")

Récupère des informations sur le système.

def test_system_info(host):
"""Récupère les informations système."""
sysinfo = host.system_info
assert sysinfo.type == "linux"
assert sysinfo.distribution in ["ubuntu", "debian", "rocky", "fedora"]
assert sysinfo.arch in ["x86_64", "aarch64"]
print(f"Distribution : {sysinfo.distribution} {sysinfo.release}")
print(f"Codename : {sysinfo.codename}")

Vérifie les paramètres kernel.

def test_kernel_params(host):
"""Vérifie les paramètres de sécurité kernel."""
# Désactiver le forwarding IP (sauf pour les routeurs)
assert host.sysctl("net.ipv4.ip_forward") == 0
# Protection contre les attaques SYN flood
assert host.sysctl("net.ipv4.tcp_syncookies") == 1

Inspecte les processus en cours d’exécution.

def test_processes(host):
"""Vérifie les processus critiques."""
# Trouver le processus nginx master
nginx_master = host.process.get(user="root", comm="nginx")
print(f"PID nginx master : {nginx_master.pid}")
# Trouver les workers nginx
workers = host.process.filter(ppid=nginx_master.pid)
assert len(workers) >= 1, "Aucun worker nginx"
# Vérifier la consommation mémoire totale
total_mem = sum(w.pmem for w in workers)
assert total_mem < 10, "Workers nginx consomment trop de mémoire"

Exécutez n’importe quelle commande et vérifiez le résultat.

def test_command_execution(host):
"""Exécute des commandes et vérifie les résultats."""
# Commande simple
cmd = host.run("uname -s")
assert cmd.rc == 0 # code de retour
assert cmd.succeeded
assert "Linux" in cmd.stdout
assert cmd.stderr == ""
# check_output retourne stdout directement
kernel = host.check_output("uname -s")
assert kernel == "Linux"
# Commande avec arguments (échappement automatique)
cmd = host.run("ls -la %s", "/etc/passwd")
assert cmd.rc == 0

Testinfra peut exécuter les tests sur différentes cibles grâce à ses backends de connexion. Par défaut, il utilise le backend local.

Les tests s’exécutent sur la machine où pytest tourne.

Fenêtre de terminal
pytest test_system.py
# ou explicitement
pytest --hosts=local:// test_system.py

Teste un serveur distant via SSH. Nécessite une authentification par clé.

Fenêtre de terminal
# Connexion simple
pytest --hosts=ssh://192.168.1.100 test_system.py
# Avec un utilisateur spécifique
pytest --hosts=ssh://admin@192.168.1.100 test_system.py
# Avec un fichier de configuration SSH
pytest --ssh-config=~/.ssh/config --hosts=ssh://webserver test_system.py
# Avec une clé privée spécifique
pytest --ssh-identity-file=~/.ssh/id_deploy --hosts=ssh://server test_system.py
# Avec timeout personnalisé
pytest --hosts='ssh://server?timeout=30&controlpersist=120' test_system.py

Teste un conteneur Docker en cours d’exécution.

Fenêtre de terminal
# Par nom de conteneur
pytest --hosts=docker://my-nginx test_system.py
# Par ID de conteneur
pytest --hosts=docker://abc123def test_system.py
# En tant qu'utilisateur spécifique dans le conteneur
pytest --hosts=docker://root@my-nginx test_system.py

Similaire à Docker, pour les environnements rootless.

Fenêtre de terminal
pytest --hosts=podman://my-container test_system.py

Utilise l’inventaire Ansible pour déterminer les hôtes et les méthodes de connexion.

Fenêtre de terminal
# Tous les hôtes de l'inventaire
pytest --hosts=ansible://all test_system.py
# Un groupe spécifique
pytest --hosts=ansible://webservers test_system.py
# Avec un inventaire personnalisé
pytest --ansible-inventory=/path/to/inventory --hosts=ansible://all test_system.py
# Utiliser Ansible pour l'exécution (plus lent mais plus compatible)
pytest --force-ansible --hosts=ansible://all test_system.py

Teste des pods Kubernetes.

Fenêtre de terminal
# Pod avec nom complet
pytest --hosts='kubectl://mypod-abc123' test_system.py
# Avec namespace et conteneur
pytest --hosts='kubectl://mypod?namespace=production&container=app' test_system.py
# Avec un contexte kubeconfig spécifique
pytest --hosts='kubectl://mypod?context=prod-cluster' test_system.py

Teste des conteneurs LXC ou LXD.

Fenêtre de terminal
pytest --hosts=lxc://my-container test_system.py

Teste des serveurs Windows (nécessite pywinrm).

Fenêtre de terminal
pytest --hosts='winrm://Administrator:Password@192.168.1.100' test_system.py

Pytest offre une fonctionnalité puissante : la paramétrisation. Elle permet d’exécuter le même test avec différentes données d’entrée, évitant la duplication de code.

import pytest
@pytest.mark.parametrize("filepath,expected_user", [
("/etc/passwd", "root"),
("/etc/shadow", "root"),
("/etc/group", "root"),
("/etc/hostname", "root"),
])
def test_system_files_ownership(host, filepath, expected_user):
"""Vérifie que les fichiers système appartiennent à root."""
f = host.file(filepath)
assert f.exists, f"Le fichier {filepath} n'existe pas"
assert f.user == expected_user, f"{filepath} appartient à {f.user}, attendu {expected_user}"

Ce test s’exécute quatre fois, une pour chaque fichier listé.

Pour tester plusieurs serveurs avec les mêmes tests :

Fenêtre de terminal
pytest --hosts=ssh://server1,ssh://server2,ssh://server3 test_system.py

Chaque test s’exécute sur chaque serveur.

Créez un fichier conftest.py pour personnaliser le comportement :

"""Configuration pytest pour Testinfra."""
import pytest
import testinfra
def pytest_addoption(parser):
"""Ajoute des options personnalisées."""
parser.addoption(
"--environment",
action="store",
default="dev",
help="Environnement cible : dev, staging, prod"
)
@pytest.fixture(scope="module")
def environment(request):
"""Retourne l'environnement cible."""
return request.config.getoption("--environment")
@pytest.fixture
def expected_packages(environment):
"""Retourne les paquets attendus selon l'environnement."""
base_packages = ["nginx", "curl", "vim"]
if environment == "prod":
return base_packages + ["fail2ban", "auditd"]
return base_packages

Utilisez ensuite ces fixtures dans vos tests :

def test_required_packages(host, expected_packages):
"""Vérifie que les paquets requis sont installés."""
for pkg_name in expected_packages:
pkg = host.package(pkg_name)
assert pkg.is_installed, f"Paquet manquant : {pkg_name}"

En plus de l’utilisation via pytest, Testinfra expose une API Python pour créer des hosts programmatiquement.

import testinfra
# Créer un host local
host = testinfra.get_host("local://")
# Vérifier un fichier
if host.file("/etc/nginx/nginx.conf").exists:
print("nginx est configuré")
# Exécuter une commande
result = host.run("systemctl is-active nginx")
print(f"nginx est : {result.stdout.strip()}")
# Créer un host SSH
remote = testinfra.get_host("ssh://admin@192.168.1.100")
print(f"Système distant : {remote.system_info.distribution}")

Cette API est utile pour écrire des scripts de validation ou intégrer Testinfra dans d’autres outils.

Testinfra s’intègre naturellement dans les pipelines CI/CD. Voici quelques exemples de configuration.

test-infrastructure:
stage: test
image: python:3.12
before_script:
- pip install pytest-testinfra[paramiko]
script:
- pytest tests/ --hosts=ssh://$DEPLOY_USER@$DEPLOY_HOST -v --junit-xml=report.xml
artifacts:
reports:
junit: report.xml
only:
- main
name: Infrastructure Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install Testinfra
run: pip install pytest-testinfra
- name: Run tests
run: pytest tests/ -v

Pour intégrer les résultats dans votre système CI :

Fenêtre de terminal
pytest tests/ --junit-xml=results.xml -v
ProblèmeCause probableSolution
ModuleNotFoundError: testinfraTestinfra pas installé dans l’environnement actifpip install pytest-testinfra dans le bon venv
Connection refused (SSH)Serveur SSH non accessibleVérifier la connectivité avec ssh user@host
Permission deniedClé SSH non autoriséeAjouter la clé publique à authorized_keys
No such container (Docker)Conteneur arrêté ou mal nomméVérifier avec docker ps
Service not foundNom de service incorrect ou système d’init différentVérifier le nom exact avec systemctl list-units
Tests très lents via SSHPas de ControlPersistUtiliser --hosts='ssh://host?controlpersist=120'
Fenêtre de terminal
pytest tests/ -v --tb=long

L’option --tb=long affiche la traceback complète en cas d’erreur.

Fenêtre de terminal
# Exécuter un seul test
pytest tests/test_system.py::test_passwd_file -v
# Avec sortie des prints
pytest tests/ -v -s
tests/
├── conftest.py # Configuration et fixtures
├── test_base.py # Tests communs à tous les serveurs
├── test_webserver.py # Tests spécifiques aux serveurs web
├── test_database.py # Tests spécifiques aux bases de données
└── test_security.py # Tests de durcissement

Utilisez des noms descriptifs qui expliquent ce qui est vérifié :

# ❌ Mauvais
def test_1(host):
...
# ✅ Bon
def test_ssh_service_is_running_and_enabled(host):
...
# ❌ Message par défaut peu informatif
assert nginx.is_running
# ✅ Message explicite
assert nginx.is_running, "Le service nginx devrait être en cours d'exécution"

Certains tests nécessitent les privilèges root :

Fenêtre de terminal
pytest --sudo tests/

Ou dans le code :

def test_shadow_file(host):
"""Vérifie /etc/shadow (nécessite sudo)."""
with host.sudo():
shadow = host.file("/etc/shadow")
assert shadow.exists
assert shadow.mode == 0o640
  • Testinfra v10.2.2 permet de valider l’état réel de vos serveurs avec des tests Python
  • Basé sur pytest, il bénéficie de la paramétrisation, des fixtures et des rapports
  • Installation : pip install pytest-testinfra
  • Modules principaux : File, Service, Package, User, Group, Interface, Socket, MountPoint
  • Backends : local, SSH, Docker, Podman, Ansible, kubectl, LXC, WinRM
  • Intégration naturelle dans les pipelines CI/CD pour valider chaque déploiement
  • Utilisez --sudo pour les tests nécessitant les privilèges root
  • La paramétrisation pytest évite la duplication de code pour tester plusieurs éléments

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