Aller au contenu principal

PyInfra un gestionnaire de configuration

· 9 minutes de lecture
Stéphane ROBERT
Consultant DevOps

Je vous propose de découvrir un gestionnaire de configuration se nommant PyInfra. PyInfra n'est pourtant pas un projet tout jeune puisque la première version date de 2016. PyInfra mérite cependant qu'on s'y attarde, car il peut rendre bien des services et se veut simple à prendre en main surtout si on connait le langage Python.

Introduction à PyInfra

PyInfra se pose en alternative à Ansible dont il en reprend les grands principes. Plutôt que d'utiliser une syntaxe à base de YAML ou JSON, il décrit ses configurations en Python permettant ainsi d'utiliser toutes les librairies de ce langage sans difficultés.

On retrouve aussi :

  • Des inventaires, appelé inventory, permettant de définir des groupes et de leur assigner des variables.
  • Des operations, l'équivalent des modules Ansible, permettant de configurer les machines cibles.
  • Des facts permettant de récupérer l'état actuel des serveurs cibles.

Premiers pas avec PyInfra

Comme d'habitude, je vais utiliser des machines provisionnées avec Vagrant. Voici un simple Vagrantfile :

# -*- mode: ruby -*-
# vi: set ft=ruby :
ENV['VAGRANT_NO_PARALLEL'] = 'yes'
Vagrant.configure("2") do |config|
  config.vm.box = "generic/ubuntu2204" # Image for all installation
  config.vm.synced_folder '.', '/vagrant', disabled: true
  config.hostmanager.enabled = true
  config.hostmanager.manage_host = true

  config.vm.define :ubuntu22 do |ubuntu|
    ubuntu.vm.provider 'libvirt' do |lv|
      lv.memory = 1024
      lv.cpus = 1
    end
  end
end

On provisionne la machine :

vagrant up

Installation de Pyinfra

PyInfra étant écrit en python, vous pouvez utiliser pip ou pipx pour l'installer.

pipx install pyinfrapyinfra: v2.5.1
PyInfra--version
pyinfra: v2.5.1

Le mode adhoc

Pour lancer une simple action, nous allons utiliser le mode adhoc :

pyinfra INVENTORY OPERATIONS...

L'inventaire peut être une simple machine ou un fichier d'inventaire. Une machine est décrite sous la forme @connecteur/nom_serveur. Le connecteur par défaut est bien sûr SSH. Mais il est possible d'en utiliser d'autres comme Local, Docker, Vagrant, Terraform, SSH... Une opération, l'équivalent des modules ansible, permet de configurer les serveurs cibles.

Pour lancer la collecte des facts sur la machine local :

pyinfra @local fact
--> Loading config...

--> Loading inventory...

--> Connecting to hosts...
    [@local] Connected

--> Gathering facts...

Un premier inventaire PyInfra

Comme je l'ai dit plus haut, les personnes utilisant Ansible ne seront pas dépaysés. On retrouve les inventaires avec les mêmes notions de groupes et de variables.

Je vais utiliser le connecteur Vagrant. Pour accèder aux machines il faudra lancer les commandes PyInfra dans le répertoire où se trouve le fichier VagrantFile.

pyinfra @vagrant/ubuntu22 fact

--> Loading config...

--> Loading inventory...
    Getting Vagrant config...

--> Connecting to hosts...
    No host key for 192.168.121.115 found in known_hosts
    [@vagrant/ubuntu22] Connected

--> Gathering facts...

Maintenant écrivons notre inventaire que nous nommons inventory.py:

nodes = [
  @vagrant/ubuntu22
]

Dans cet inventaire, nous définissons le group nodes dans lequel nous ajoutons notre machine vagrant. Pour lancer notre configuration sur une seule machine, nous pouvons utiliser l'option --limit.

On relance la commande en utilisant notre fichier d'inventaire en se limitant au groupe nodes :

pyinfra inventory.py fact --limit nodes

Ecriture de notre première configuration

Comme dit plus haut, les configurations sont écrites en Python dont voici la structure minimale :

from pyinfra.operations import apt

apt.packages(
    name     = "Ensure ssh is installed",
    packages = ["ssh"],
    update   = True,
    _sudo    = True,
)

Dans cet exemple, nous allons utiliser l'opération apt.packages puisque notre machine est une Ubuntu. Contrairement à Ansible il n'y pas de modules package gérant l'ensemble des services de packages rencontrés.

Pour définir les paramètres, il faut se rendre sur la documentation de l'opération apt

Pour l'élévation de droits pour une opération, il suffit d'utiliser le paramètre _sudo. Pour retrouver la liste de tous paramètres globaux, c'est ici.

Nommer ce fichier deploy.py. Et lançons son exécution :

pyinfra inventory.py provision.py

--> Loading config...

--> Loading inventory...
    Getting Vagrant config...

--> Connecting to hosts...
    No host key for 192.168.121.115 found in known_hosts
    [@vagrant/ubuntu22] Connected

--> Preparing Operations...
    Loading: provision.py
    [@vagrant/ubuntu22] Ready: provision.py

--> Proposed changes:
    Groups: inventory / @vagrant / nodes
    [@vagrant/ubuntu22]   Operations: 1   Change: 1   No change: 0


--> Beginning operation run...
--> Starting operation: Ensure ssh is installed
    [@vagrant/ubuntu22] Success


--> Results:
    Groups: inventory / @vagrant / nodes
    [@vagrant/ubuntu22]   Changed: 1   No change: 0   Errors: 0

Super notre package est bien présent. Par contre, si je relance à nouveau la commande, je vois toujours changed 1 dans le rapport final. Pour déboguer, il suffit de lancer an ajoutant l'option -vvv qui affiche toutes les opérations effectuées par pyinfra. Ah c'est le paramètre update qui fait ce changement d'état ce qui est vrai. Si on le retire et que l'on relance notre configuration, nous obtenons bien changed:0.

Utilisation des facts

Bon voilà cela fonctionne, mais comment faire si nous avons plusieurs variantes de Linux pour différencier les opérations. L'équivalent du when d'Ansible ? Nous allons utiliser les facts.

from pyinfra import host
from pyinfra.operations import apt
from pyinfra.facts.server import LinuxName

if host.get_fact(LinuxName) == "Ubuntu":
    apt.packages(
        name     = "Ensure ssh is installed",
        packages = ["ssh"],
        update   = True,
        _sudo    = True,
    )

Nous allons faire appel à l'opération host.get_fact pour récupérer le type de Linux. Ce fact se trouve dans les facts de type server et se nomme LinuxName. Tous les facts sont documentés ici. Nous aurions pu utiliser aussi server.LinuxDistribution.

Nous relançons notre configuration. Cela fonctionne comme attendu.

Une configuration plus avancée

Je vous propose de configurer notre machine vagrant pour qu'elle accepte les connexions ssh avec la clé ed25519 de mon compte personnel :

from pyinfra import config,host
from pyinfra.operations import apt,files,server,systemd
from pyinfra.facts.server import Hostname, LinuxName
import os

config.SUDO = True

if host.get_fact(LinuxName) == "Ubuntu":
    apt.packages(
        name     = "Ensure ssh is installed",
        packages = ["ssh"],
        update   = True,
        _sudo    = True,
    )

    systemd.service(
        name="Restart and enable sshd",
        service="ssh.service",
        running=True,
        restarted=True,
        enabled=True,
    )

    files.line(
        name = "",
        path = "/etc/ssh/sshd_config",
        line = r"^PasswordAuthentication*",
        replace = "PasswordAuthentication yes",
        present = True
    )

    key_file = open("%s/.ssh/id_ed25519.pub" % os.environ["HOME"], "r")
    key = key_file.read().strip()

    files.line(
        name = "",
        path = "/home/vagrant/.ssh/authorized_keys",
        line = key,
        present = True
    )

Pour cela, j'ai utilisé les opérations systemd.service et files.line. Pour récupérer le contenu d'un fichier, on utilise le code python classique de lecture de fichier.

Nous relançons, testons la connexion en ssh :

ssh vagrant@ubuntu22
Warning: Permanently added 'ubuntu22' (ED25519) to the list of known hosts.
Last login: Fri Oct 28 06:33:16 2022 from 192.168.121.1
vagrant@ubuntu2204:~$

Cela veut dire que nous pouvons désormais utiliser le connecteur ssh plutôt que Vagrant pour nos prochaines configurations.

pyinfra --user vagrant ubuntu22 fact

--> Loading config...

--> Loading inventory...

--> Connecting to hosts...
    No host key for ubuntu22 found in known_hosts
    [ubuntu22] Connected

--> Gathering facts...

Utilisation des variables dans les inventaires

Nous allons enrichir notre inventaire en ajoutant un groupe contenant notre machine à laquelle nous définissons une variable :

nodes = [
    "@vagrant/ubuntu22",
]

ssh_nodes = [
    ("ubuntu22", {"packages": ["htop","net-tools"]})
]

Voyons comment utiliser cette variable dans une nouvelle configuration :

from pyinfra import config,host
from pyinfra.operations import apt
from pyinfra.facts.server import LinuxName

if host.get_fact(LinuxName) == "Ubuntu":
    apt.packages(
        name     = "Install all packages",
        packages = host.data.get("packages"),
        _sudo    = True,
    )

Lançons notre configuration en limitant sur le groupe ssh_nodes :

pyinfra -v --user vagrant inventory.py provision2.py --limit ssh_nodes
pyinfra -v --user vagrant inventory.py provision2.py --limit ssh_nodes

--> Loading config...

--> Loading inventory...
    Getting Vagrant config...

--> Connecting to hosts...
    No host key for ubuntu22 found in known_hosts
    [ubuntu22] Connected

--> Preparing Operations...
    Loading: provision2.py
    [ubuntu22] Loaded fact server.LinuxDistribution
    [ubuntu22] Loaded fact deb.DebPackages
    [ubuntu22] noop: package htop is installed (3.0.5-7build2)
    [ubuntu22] Ready: provision2.py

--> Proposed changes:
    Groups: inventory / ssh_nodes
    [ubuntu22]   Operations: 1   Change: 1   No change: 0


--> Beginning operation run...
--> Starting operation: Install all packages
    [ubuntu22] Success


--> Results:
    Groups: inventory / ssh_nodes
    [ubuntu22]   Changed: 1   No change: 0   Errors: 0

Si on regarde les traces du mode verbose, nous pouvons remarquer que pyinfra fait une première analyse des packages présents pour identifier quels sont ceux à vraiment installer. Je me suis amusé à ajouter une seconde opération apt et le test est bien fait en amont. Clairement surpris et de la bonne manière. On définit bien un état à obtenir et non une suite d'opérations à effectuer. Une gestionnaire de configuration de type déclaratif. À vérifier sur d'autres types d'opérations

Toutes les opérations sont classées par catégorie :

  • Basics : Files Operations, Server Operations, Git Operations, Systemd Operations
  • System Packages : Apt Operations, Apk Operations, Brew Operations, Dnf Operations, Yum Operations
  • Language Packages : Gem Operations, Npm Operations, Pip Operations
  • Databases : Postgresql Operations, Mysql Operations

Leurs documentations sont documentées ici. Vous remarquerez que la liste est plutôt courte, mais on y retrouve le minimum vital.

Plus loin avec PyInfra

Maintenant que j'ai écrit ma première configuration PyInfra, voici les avantages que je vois :

  • Comme Ansible, il est agentless.
  • Il dispose de multiples connecteurs comme vagrant, docker, terraform, ansible, winrm ... et ssh bien sur.
  • Quelqu'un connaissant le langage python ne sera pas perdu. En effet, PyInfra n'utilise pas de syntaxe basée sur du JSON, ni du YAML mais écrit ses configurations en Python. Ce qui permet d'utiliser toutes les fonctionnalités de ce langage, comme les conditions, les tests, les décorateurs...
  • Comme ses configurations sont écrites en Python le débogage est facile.
  • Il possède de multiples opérateurs pour configurer ses cibles : server, files, user, apt, apk, dnf, git, mysql,...
  • Il intégre un collecteur de facts
  • Il peut être étendu par des développements maison pour ajouter des operations ou des connectors.
  • Il possède une API
  • On a bien un gestionnaire de configuration déclaratif !

Au final, il faut poursuivre plus loin les tests, mais PyInfra me donne vraiment envie de l'utiliser. PyInfra a clairement un gros potentiel !!!!