Suite de la série d'articles à propos de la qualité logicielle dans un projet. Aujourd'hui, deuxième étape : mettre en place la gestion des configurations en fonction des environnements.
Petit rappel de contexte : il s'agit d'une série d'articles dans laquelle je liste tous les trucs que je mets en place sur un projet avant de commencer à coder.
Dans la première partie, nous avions parlé du choix de l'interpréteur et de la façon de gérer les dépendances.
Aujourd'hui, on va parler réglages et environnements.
Expliquons ça avec un truc physique. Prenons par exemple un robot 🤖
Un robot qui fonctionne à l'énergie solaire, et qui est donc programmé pour démarrer seulement quand il y a du soleil. Sa tâche, c'est d'ouvrir le coffre-fort pour sortir le matériel de ménage, et faire le ménage.
L'environnement, c'est l'endroit où l'on démarre notre robot, l'heure de la journée, l'endroit où se trouve le coffre-fort, le code du coffre-fort, le plan de la maison, etc.
Si le robot a été conçu à Paris, en plein mois d'août, alors il pourrait naturellement être réglé sur l'heure de Paris. Et on va lui dire que le soleil se lève à 6h20 et se couche à 21h30. On va aussi lui dire que le coffre est dans la 3ème chambre, et que le code est 6539.
En revanche, à Reykjavik, en plein mois de janvier, le soleil se lève en réalité à 10h50 et se couche à 16h23. Et la maison n'est pas du tout foutue pareil.
Il est donc évident que notre robot doit avoir un système lui permettant de savoir, en fonction de l'endroit où il se trouve, les heures de lever et de coucher de soleil. Pareil pour le coffre, le plan de la maison, etc. vous m'avez compris.
Au niveau d'un logiciel, c'est à peu près la même chose. Il a besoin des informations à propos du serveur sur lequel il tourne, les adresses et mots de passe des différents services auxquels il doit se connecter, etc.
On peut avoir tendance à négliger tout ça, au début. En effet, ce qu'on veut voir dans un premier temps, c'est un logiciel qui fonctionne, qui fait ce qu'on lui demande. On peut facilement hardcoder (indiquer directement dans le code) toutes les informations qui devraient être des paramètres d'environnement.
Mais ce super programme qu'on commence à écrire, et qui va à coup sûr révolutionner le monde, va être amené à fonctionner sur différentes machines. Au minimum, celle sur laquelle il est développé, et celle sur laquelle il tournera en production. Selon ces environnements, on va avoir des réglages qui seront différents. Un cas typique : les informations de connexion à la base de données.
Pendant le développement, sur son ordinateur, on va mettre en place une base de donnée locale, avec un couple user/password. En production, on aura peut-être besoin de se connecter à une base externe, avec un couple user/password sécurisé. Peut-être même qu'on mettra en place une autre méthode d'authentification.
À la base, dans son code, ou aura tendance à écrire directement les identifiants et mots de passe :
database = Database(
user='monUsername',
password='monPassword',
host='127.0.0.1',
port=5432)
Quand on passera en production, il faudra venir modifier ce fichier, qui est versionné.
Et c'est là qu'on arrive au concept de réglage d'environnement : on va toujours vouloir éviter de changer des fichiers versionnés pour faire fonctionner son programme sur un environnement en particulier. On sortira ces informations du code, pour en faire des paramètres.
git status
, vous voyez des fichiers modifiés, mais que "c'est normal, c'est pour que ça fonctionne chez moi", alors ces informations devraient dépendre de l'environnement. Cela peut aller du simple couple user/mot de passe à un choix de briques logicielles à utiliser.
Ça parait évident pour une connexion à une base de données, mais ça doit l'être pour tout le reste, pour tout ce qui dépend de l'environnement : tous les services externes, des paths éventuels qui ne pourraient pas être résolus par le code, la configuration du serveur de fichiers statiques/medias, etc.
Ça doit tourner chez vous, et ça doit tourner partout, à moindre effort.
Afin de se mettre d'équerre, et de faire un truc future proof, on a plusieurs solutions qui s'offrent à nous.
La plus basique, c'est de créer un fichier de réglages non versionnés, contenant les réglages spécifiques à l'environnement.
Imaginons le fichier settings.py
(versionné) suivant :
from local_settings import *
DB_PORT = 5432
Et le fichier local_settings.py
, qui n'est pas versionné (présent dans le fichier .gitignore
) :
DB_USER = 'casimir'
DB_PASSWORD = 'p3ndu4uMuR'
DB_HOST = '127.0.0.1'
Dans notre code, on importe les réglages à partir du fichier versionné, et on les utilise de cette manière :
import settings
database = Database(
user=settings.DB_USER,
password=settings.DB_PASSWORD,
host=settings.DB_HOST,
port=settings.DB_PORT)
Et quand on passera en production, on n'aura plus qu'à créer un fichier local_settings.py
sur notre environnement de production :
DB_USER = 'secret_user'
DB_PASSWORD = '*********'
DB_HOST = '12.92.73.72'
C'est souvent avec cette stratégie qu'on débute (ça marche très bien avec un Wordpress par exemple).
Ce fichier doit rester non versionné. Afin de documenter facilement ce qu'on doit mettre dans ce fichier (pour tout ceux qui rejoignent le projet par exemple), on peut créer un fichier d'exemple local_settings.example.py
, à copier/coller et à modifier. Ce fichier contiendra des valeurs d'exemples, et non vos valeurs par défaut.
DB_USER = 'put_your_username_here'
DB_PASSWORD = 'put_your_password_here"
DB_HOST = 'db_address_here'
À plus long terme, les réglages deviennent complexe : beaucoup de clés, des types de données différents, des services différents en développement et en production, et parfois même des choses qui se font programmativement. On aura alors des fichiers de réglage très différents selon l'environnement, et un simple local_settings.example.py
devra être accompagné d'une documentation exhaustive.
Il y a des techniques alternatives offrant d'autres avantages.
Une solution peut être d'utiliser des variables d'environnement.
Une variable d'environnement est une valeur dynamique, chargée en mémoire, pouvant être utilisée par plusieurs processus fonctionnant simultanément.
En gros, c'est une valeur qui est stockée sur la machine, et sur laquelle on peut s'appuyer pour récupérer des réglages. Dans un terminal, on déclare une variable d'environnement de cette manière :
export MA_VALEUR=5
Le problème des variables d'environnement, c'est qu'elles sont propres à une machine (et même à une session en réalité). Elles ne seront pas versionnées ou déclarées quelque part. Il faut penser à les déclarer.
En python, on pourrait imaginer un pattern comme ça :
import os
database = Database(
user=os.environ.get('DB_USER'),
password=os.environ.get('DB_PASSWORD'),
host=os.environ.get('DB_HOST'),
port=os.environ.get('DB_PORT'))
Mais ça implique de bien penser à déclarer toutes ces variables.
Et c'est là que les fichiers dotEnv (en vérité .env
) viennent nous donner un coup de main. Cet article en parle très bien.
En gros, l'idée, c'est de pouvoir déclarer les variables d'environnements du projet dans un fichier présent à la racine du projet. Ça permet potentiellement de versionner ces réglages, de déclarer des environnements, et ensuite de récupérer ces valeurs dans le code comme si c'étaient des vrais variables d'environnement, à l'aide de librairies comme python-dotenv par exemple.
On aurait alors un fichier .env
qui ressembleraient à ça :
DB_USER=casimir
DB_PASSWORD=p3ndu4uMuR
DB_HOST=127.0.0.1
DB_PORT=5432
Et, quelque part dans notre code, avant d'utiliser les variables d'environnement, il faudrait faire ça :
from dotenv import load_dotenv
load_dotenv() # Là, ça a mis à jour l'environnement en utilisant les valeurs du .env
On peut versionner un fichier .env.example
, pour lister les valeurs à renseigner.
Il y a quelques inconvénients à mes yeux. On va stocker uniquement des clés valeurs, pas de formats complexes.
Ce qui fait que j'ai tendance à préférer la méthode suivante.
Ici, le principe, c'est d'utiliser une seule variable d'environnement qui indique le module de configuration à utiliser.
Cette variable peut s'appeler par exemple SETTINGS_MODULE
. Dans le point d'entrée de notre application, on pourra donner lui donner une valeur par défaut :
#!/usr/bin/env python
import os
if __name__ == '__main__':
os.environ.setdefault('SETTINGS_MODULE', 'my_app.settings.dev')
De cette manière, si la variable d'environnement n'existe pas, il utilisera les réglages de développement.
Ensuite, sans trop rentrer dans le détail de l'implémentation, on écrit du code pour aller charger les données contenues dans le module de settings renseigné par la variable d'environnement.
Stratégiquement, je mets en place un système de chargement des réglages en cascade, avec tout d'abord un fichier base.py
qui contient tous mes réglages de base, qui ne changent pas quels que soient les environnements. Ensuite, je crée un fichier de réglage par type d'environnement, fichier qui va inclure les paramètres contenus dans base.py
.
Typiquement, je vais avoir un fichier dev.py
qui va ressembler à ça :
from .base import * # je charge tous les réglages de base
MY_SETTING = "my value"
Ce qui est intéressant, c'est que comme c'est un fichier python, on peut y faire à peu prêt ce qu'on veut. Les réglages peuvent être des chaines de caractères, des dictionnaires ou des tuples, et peuvent très bien être calculés programmatiquement. Typiquement pour récupérer des chemins par exemple :
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
Si besoin, je peux avoir un fichier de configuration spécifique pour la production ou pour l'intégration continue.
Selon moi, il est important de faire une distinction entre la notion de réglage/configuration, et la notion de secrets.
Les secrets correspondent à toutes les informations sensibles, qui ne devraient pas être versionnées. J'y inclus les couples utilisateurs / mots de passes, les token d'API, mais également des urls d'API externes par exemple.
J'ai fais le choix d'ajouter, dans mes projets, un fichier secrets.json
non versionné, contenant les clés valeurs de chacun des secrets dont j'ai besoin :
{"DB_USER":"casimir",
"DB_PASSWORD":"p3ndu4uMuR",
"DB_HOST":"127.0.0.1",
"DB_PORT":"5432"}
Je commit évidemment à côté de ça un fichier d'exemple, que je crée par habitude sous le nom sample.json
. En général je fais quelque chose comme ça :
{"DB_USER":"put_username_here",
"DB_PASSWORD":"put_password_here",
"DB_HOST":"put_host_here",
"DB_PORT":"5432"}
Ensuite, dans mes réglages, j'ai implémenté une méthode qui permet d'aller chercher la valeur, d'abord dans les variables d'environnements, ensuite dans le fichier json. Cela me permet d'overrider localement un réglage si besoin. Ca me permet aussi de déployer sur des environnements qui sont plutôt orientés configurations par variables d'environnement, comme chez Heroku, ou alors pour une utilisation avec docker.
# JSON-based secrets module
try:
with open(os.path.join(SETTINGS_DIR, 'secrets.json'), encoding='utf-8') as f:
secrets = json.loads(f.read())
except FileNotFoundError: # pragma: no cover
secrets = json.loads('{}')
def get_secret(setting, secret_file=secrets):
if env_secret := os.environ.get(setting):
return env_secret
try:
return secret_file[setting]
except KeyError as key_error: # pragma: no cover
error_msg = f'Set the {setting} secret variable'
raise ImproperlyConfigured(error_msg) from key_error
Mes réglages ressemblent alors à ça :
DB_USER=get_secret('DB_USER')
DB_PASSWORD=get_secret('DB_PASSWORD')
DB_HOST=get_secret('DB_HOST')
DB_PORT=get_secret('DB_PORT')
À noter que c'est tout à fait compatible avec l'utilisation de fichiers dotEnv, du coup.
Pour être complet, si on veut vraiment sécuriser ses credentials, on peut utiliser un gestionnaire de mot de passe type Vault. Tous les secrets seront alors stockés dans le Vault et l'application ira interroger l'API sécurisée du logiciel pour les récupérer. L'avantage, c'est qu'à aucun moment on ne laisse apparaitre le moindre secret dans un fichier (même non commité) ou dans une variable d'environnement.
Un dernier point avant de partir, qui fait écho avec le premier article : selon les environnements, on aura besoin de dépendances différentes. En particulier, on aura souvent besoin d'installer des outils pendant le développement, pour aider au debug. On ne voudra pas installer ces dépendances en production.
Npm embarque nativement une notions de dépendances de développement. C'est pas mal.
Par contre, il est tout à fait possible qu'on ne se limite pas à des dépendances dev / dépendances prod.
En python, les dépendances peuvent être listée dans un fichier texte, qu'on appelle requirements.txt
. On utilise alors la commande pip install -r requirements.txt
pour installer toutes les dépendances.
Ce qui est intéressant, c'est que, d'une part, ce fichier requirements.txt
n'est qu'une convention, on peut très bien l'appeler n'importe comment, et ce fichier peux lui-même faire référence à d'autres fichiers. Par exemple, pour un fichier patate.txt
:
-r un_autre_fichier.txt
pylint
ipython
ipdb
Lorsqu'on fait un pip install -r patate.txt
, pip ira installer les dépendances contenues dans le fichier un_autre_fichier.txt
, avant d'aller installer les trois dépendances inclues directement dans le fichier (pylint, ipython et ipdb).
On peut donc utiliser cela pour mettre en place un système de dépendances par environnement. De mon côté, j'ai choisi d'utiliser le pattern suivant :
base.txt # toutes mes dépendances de base
test.txt # uniquement les dépendances nécessaires pour faire tourner les tests
docs.txt # uniquement les dépendances nécessaires pour construire la ou les documentations
dev.txt # tire les dépendances de base.txt, test.txt et docs.txt, et ajoute des dépendances spécifique pour le développement
ci.txt # tire les dépendances nécessaires pour faire tourner l'application sur la ci. En général, seulement base.txt et test.txt, éventuellement docs.txt
prod.txt # tire les dépendances de base et y ajoute les éventuels paquets nécessaires uniquement en production (les packages heroku par exemple, tmtc)
Dans le prochain article, on attaquera une partie bien plus proche du code : on parlera de code styling.
2022 - tominardi.fr