PyConFR 2026 - Biarritz Samedi 31 octobre - 16 h 30 - Salle MARI/SUGGAR
Loïc "wowi42" Tosser CTO chez Kalvad
github.com/wowi42 codeberg.org/wowi42 @wowi42
C'est le moment de le dire. Même ton IDE ne te jugera pas.
Vous êtes entre amis. Celui qui dit "c'est moins pire que XML" paie l'apéro.
(Après Kubernetes. Et l'imprimante du bureau.)
Que les Ops apprennent des devs, et que les devs apprennent des Ops.
Python peut être ce langage commun.
Le YAML reste ici uniquement pour les besoins de la démonstration.
Le YAML n'a qu'à bien se tenir.
# playbook.yml - 847 lignes. Personne n'ose y toucher. Même son auteur. - name: "Déployer {{ app | d('le bordel') }} sur {{ env | upper if env is defined else 'PROD???' }}" hosts: "{{ lookup('env','HOSTS')|split(',')|reject('==','')|list if lookup('env','HOSTS')|d('')!='' else ['all'] }}" vars: norway: no # false. Le fameux bug scandinave. enabled: yes # true. Évidemment. disabled: off # false. Bien sûr que c'est logique. tokyo: on # true. YAML fait aussi de la géographie. nope: n # false. CQFD. yep: y # true. Vous suivez ? Personne ne suit. port: "{{ (lookup('env','PORT')|d(8080)|int)+((groups['web']|d([])|length)*10) if groups['web'] is defined else 8080 }}" nested: "{{ '{{' }} lookup('template','templates/'+(('{{' }} item.key {{ '}}') if item.key is defined else 'default')+'.j2') {{ '}}' }}" &common: timeout: "{{ 300 if env=='prod' else 600 }}" retries: "{{ [1,2,3,5,8]|random }}" tasks: - name: "Installer {{ item.p | d('un truc') }}" become: "{{ yes if ansible_os_family!='Windows' else no }}" package: name="{{ item.p|regex_replace('^py[23]?-','python3-') if item.py else item.p }}" state=present loop: "{{ pkgs|d([])|union(extra_pkgs|d([]))|intersect(allowed|d(pkgs|d([])))|list }}" when: - pkgs is defined or extra_pkgs is defined - "{{ lookup('env','SKIP')|d('no')|bool == false }}" - ansible_distribution == 'Debian' and ansible_distribution_version is version('11','>=') or ansible_distribution == 'Ubuntu' and ansible_distribution_version is version('20.04','>=') - name: "Config {{ nested|count }} variables" template: src: "{{ 'templates/'+app+'/'+env+'/'+item.key+'.j2' if item.key is defined else 'templates/default/'+env+'.j2' }}" dest: "/etc/{{ app|d('app') }}/{{ item.key|d('default')|regex_replace('[^a-zA-Z0-9._-]','_') }}.conf" mode: "{{ '0644' if not item.secret|d(false) else '0600' }}" with_dict: "{{ configs|d({})|combine(more|d({})) }}" notify: "Restart {{ app }}" tags: "{{ item.value.tags|d(['config'])+['always-'+env] }}"
# Tes 7 premiers clusters Kubernetes déploient nickel. # Le 8ème explose. Pourquoi ? revisions: - 07 # -> 7 (nombre) - 08 # -> "08" (chaîne. merci l'octal, au revoir la prod) # Tu voulais écrire "4h30 du matin" ? reveil: 04:30 # YAML entend : 16200 secondes depuis minuit
Le seul format où 07 et 08 n'ont pas le même type, et où 04:30 est un nombre.
07
08
04:30
versions: foo: 1.7 # même version que... bar: 1.70 # ...celle-ci. Évidemment. fizz: 1.7.0 # différent de... buzz: 1.70.0 # ...celle-là. Bien sûr. deploy: revision: ${GIT_SHORT_SHA} # string ~98% du temps revision_safe: "${GIT_SHORT_SHA}" # string ~100% du temps
Un SHA court tout en chiffres (ça arrive 2% du temps), et ton pipeline typé explose. Bonne chance pour le reproduire.
"A YAML file is almost always still 'valid' even if it is trunca
— @colmmacc, tweet volontairement coupé (comme votre fichier)
0666
Toutes ces horreurs, et bien d'autres, sont répertoriées sur noyaml.com.
On a eu l'Infrastructure as YAML.
L'un se teste. L'autre se prie.
Il est écrit que seul un gnome des Forêts du Nord unijambiste dansant à la pleine lune au milieu des douze statuettes enroulées dans du jambon arrivera à lire des templates Helm en entier
TASK [Deploy app] ******************* fatal: [web01]: FAILED! => {"msg": "The task includes an option with an undefined variable. The error was: 'dict object' has no attribute 'nonexistent'\n\nThe error appears to be in '/playbook.yml': line 42, column 3, but may be elsewhere in the file depending on the exact syntax problem."}
"quelque part dans le fichier" - Autrement dit: débrouille-toi.
La solution officielle: -vvvv, un café et 1000 lignes de logs.
-vvvv
# 200 lignes de YAML pour tester 50 lignes de YAML, avec Tox et Molecule # Plus Docker, plus Vagrant, plus un doctorat en souffrance
Tu voulais tester ton infra. Tu dois maintenant tester les tests de ton infra.
Le seul YAML restant est dans les exemples de ce talk.
>>> import antigravity # Pour enfin décoller du YAML >>> from yaml import pain ImportError: No more. We're done here. >>> import pyinfra >>> # Ça va déjà mieux.
Pas besoin de Python sur les systèmes cibles: juste un accès shell POSIX !
Votre serveur n'a même pas besoin de savoir qu'il fait du Python.
# Installer et configurer nginx from pyinfra.operations import apt, systemd apt.packages( name="Install nginx", packages=["nginx"], ) systemd.service( name="Restart and enable the nginx service", service="nginx.service", running=True, restarted=True, enabled=True, )
Du Python en entrée, du shell en sortie.
Le YAML peut sortir de la pièce.
def
for
with_items
if/else
import
pytest
mypy
[server for server in fleet if server.is_alive()]
Votre IDE arrête enfin de faire semblant.
# SSH vers des serveurs (le classique) pyinfra @ssh/web1.example.com deploy.py # Exécuter en local (casser chez toi d'abord) pyinfra @local deploy.py # Cibler des conteneurs Docker directement pyinfra @docker/my-container deploy.py # Mixer dans l'inventaire pyinfra inventory.py deploy.py
# inventory.py - C'est juste du Python ! ssh_hosts = ["web1", "web2", "db1"] docker_hosts = [("@docker/app", {"env": "staging"})] local = ["@local"]
Un outil. Toutes les cibles. Même celle nommée "prod-ne-pas-toucher".
import requests hosts = requests.get("https://api/hosts").json() import boto3 instances = boto3.client('ec2').describe_instances() from slack_sdk import WebClient slack = WebClient(token=TOKEN) slack.chat_postMessage(channel="#ops", text="Deploy complete!")
Pas de plugins custom. Juste pip install. Plus de 400 000 packages sur PyPI.
pip install
Tu connais déjà la commande. Et les regrets qui vont avec.
from pyinfra.api import Config, Inventory, State from pyinfra.api.operations import run_ops from pyinfra.operations import server inventory = Inventory((["web1.example.com"], {})) state = State(inventory, Config()) server.shell(commands=["echo 'Deployed from my Flask app!'"]) run_ops(state)
De l'automatisation d'infrastructure dans VOTRE appli. Intégrez dans Django, FastAPI, des outils CLI.
Un import de plus. Un outil de moins.
Le combat que ton équipe plateforme attendait. Ou pas.
Ansible :
PyInfra :
Jusqu'à 10x plus rapide en déploiement
Benchmark maison, donc parfait.
- name: Install packages hosts: webservers become: yes tasks: - name: Update apt cache apt: update_cache=yes - name: Install packages apt: name="{{ item }}" state=present with_items: [nginx, postgresql, redis]
from pyinfra.operations import apt apt.packages(packages=["nginx", "postgresql", "redis"], update=True)
Tu préfères lire du code ou résoudre une énigme ?
- name: Configure based on environment template: src: "{{ 'prod.j2' if env == 'production' else 'dev.j2' }}" dest: /etc/app/config when: env is defined
if env == "production": template = "prod.j2" else: template = "dev.j2" files.template(src=template, dest="/etc/app/config")
Le même if que dans tes applis. Et il marche aussi ici.
if
- name: Create users user: name: "{{ item.name }}" groups: "{{ item.groups }}" with_items: - { name: alice, groups: sudo } - { name: bob, groups: developers } when: item.name != 'root'
users = [{"name": "alice", "groups": ["sudo"]}, {"name": "bob", "groups": ["developers"]}] for user in users: if user["name"] != "root": server.user(user=user["name"], groups=user["groups"])
Une boucle for. Tu la connais déjà.
debug: msg="{{ variable }}"
breakpoint()
Arrête de déboguer tes outils de débogage.
Ctrl+clic remplace Ctrl+F dans 47 fichiers.
from my_company.infra import setup_monitoring, configure_firewall
Une fonction. Un seul endroit pour la casser.
Chiffres mesurés au doigt mouillé. Mais avec beaucoup de conviction.
Ansible a des milliers de modules !
PyInfra a plus de 400 000 packages Python. pip install requests > module Ansible custom.
pip install requests
Tout le monde connaît Ansible !
Ici, tout le monde connaît Python. Et personne ne veut relire un when de quatre lignes.
when
Le déclaratif c'est mieux !
PyInfra EST déclaratif. Ce n'est pas le YAML qui fait la magie, c'est le diff.
Et les dépendances sur les systèmes cibles ?
Zéro. Sans agent. N'importe quel shell POSIX-ish.
Le changement fait peur !
Votre playbook de 5000 lignes que personne n'ose toucher aussi.
La peur est déjà là. Autant qu'elle lise du Python.
uv tool install pyinfra # Tu peux passer à la partie où ça marche.
# deploy.py from pyinfra.operations import apt, systemd apt.packages(name="Install nginx", packages=["nginx"]) systemd.service( name="Start nginx", service="nginx.service", running=True, enabled=True, )
pyinfra @local deploy.py # Et voilà.
C'est tout. De l'IaC avec du vrai code. Aucun sacrifice sous la pleine lune.
import pyinfra
pyinfra.com - github.com/pyinfra-dev/pyinfra - uv tool install pyinfra
uv tool install pyinfra
return "Allez libérer votre infrastructure !"
pyinfra.com → Votre futur sans YAML commence maintenant
if encore_du_ansible_apres_ce_talk: raise FriendshipError("Faut qu'on parle")
Petite bière ?
YAML: le seul langage où une espace en trop transforme ton firewall en passoire et ta garde en retraite spirituelle.