Aller au contenu principal

Dagger.io - Utiliser dagger pour automatiser vos workflows CI

Automatisez vos workflows CI/CD avec Dagger.io et Python. Lancez rapidement vos outils de qualité de code (Pylint, Black, Ruff) et testez tout votre pipeline en local pour simplifier l'intégration.

· 14 minutes de lecture
Voir la source
Christophe
Amoureux de Markdown, WSL et Docker ~ Curieux insatiable.
Cet article existe aussi en anglais — lire la version originale.

Traduction automatique

Cette traduction a été produite par une IA. Elle peut contenir des contresens ou des maladresses, en particulier sur les termes techniques. En cas de doute, consultez l'article de référence en anglais — c'est lui qui fait foi.

Journal des modifications

Dernière
Restructured for time-to-value: the local pipeline proof now comes before the setup steps.
Fixed dead link: Dagger GitLab docs moved from /integrations/gitlab to /ci/integrations/gitlab.

Dagger.io - Utiliser dagger pour automatiser vos workflows CI

⚡
TL;DR

Cet article montre comment « daggeriser » un projet Python avec Dagger.io afin que les étapes de CI (Pylint, Black, mypy, Ruff) soient définies une seule fois dans des fonctions Python et s'exécutent à l'identique en local (via un CLI dagger dockerisé, enveloppé dans des cibles make lint/make format) et dans une CI distante comme GitLab — fini la boucle lente « push et prie » pour déboguer un .gitlab-ci.yml uniquement sur le serveur. On y voit dagger init, l'écriture des fonctions du pipeline, leur exécution concurrente avec run-all, et la configuration du runner GitLab pour partager le socket Docker.

Attention, c'est une bombe. Docker a révolutionné le monde ; n'ayons pas peur de le dire haut et fort, et il y a fort à parier que Dagger.io, créé par les mêmes personnes que Docker, suivra le même chemin.

Dagger.io veut être un outil qui vous permet d'exécuter les étapes d'un workflow exactement de la même manière qu'un système de CI/CD (Continuous Integration / Continuous Delivery) comme ceux de GitHub, GitLab, Jenkins, ...

Mais qu'est-ce qu'une CI ? C'est une étape réalisée par votre serveur une fois que vous avez poussé une nouvelle version de votre projet. Pendant une CI, vous pouvez valider la syntaxe de votre code, vérifier qu'il respecte certaines règles (de formatage par exemple), lancer des outils d'analyse de qualité de code comme la vérification que vous n'avez pas de variables non déclarées ou non typées, ni de code mort (par exemple une fonction que vous n'utilisez plus).

Pendant une CI, vous pouvez aussi lancer vos tests unitaires et les exécuter à chaque nouvelle version poussée.

Le but de la CI, c'est de ... planter dès que quelque chose n'est pas dans l'état attendu ; par exemple vous avez oublié un ;, vous avez une violation (des espaces à la place de tabulations), un test unitaire ne passe plus, etc. L'objectif de la CI est donc de garantir que votre code est excellent.

Le résultat : un pipeline qui tourne, sans YAML, sans push​

Une fois Dagger branché sur un petit projet Python, dagger init génère un pipeline fonctionnel en deux minutes environ — aucun serveur CI impliqué, aucun .gitlab-ci.yml à pousser puis attendre :

>user@machine: ~/yourproject

Et le pipeline est immédiatement appelable, directement sur votre machine :

>user@machine: ~/yourproject

Deux fonctions d'exemple (container-echo, grep-dir) existent dès le départ — la preuve qu'un pipeline Dagger n'est rien d'autre que des fonctions Python que vous pouvez lancer en local. La suite de cet article transforme ces exemples en véritables étapes Pylint/Black/mypy/Ruff, puis branche exactement le même pipeline dans une CI GitLab.

Pourquoi ça fonctionne​

  • Les étapes de CI sont de simples fonctions Python — aucun DSL YAML à apprendre, aucune syntaxe qui n'existe que sur le serveur.
  • Exactement la même fonction tourne sur votre portable et dans GitLab/GitHub : même container, même sortie, plus de surprise après un push.
  • Dagger exécute tout dans un container qu'il contrôle, donc les outils dont votre pipeline a besoin (Pylint, Black, mypy, Ruff, ...) sont figés et reproductibles, pas « ce qui traîne sur le runner ».
  • Les fonctions se composent : appelez-en une pour linter, ou appelez une fonction run-all pour lancer lint/format/type-check/ruff en parallèle et s'arrêter au premier échec.
  • La vieille boucle push → attendre → lire le log → deviner → repousser peut être entièrement jouée hors ligne, avant que quoi que ce soit n'atteigne le serveur.

Installation​

Dans ce tutoriel, nous allons daggeriser un repository, c'est-à-dire partir de zéro, créer un petit script Python, créer une image Docker Dagger, puis initialiser notre projet pour utiliser Dagger.

Créons un dossier temporaire pour Dagger et plaçons-nous dedans : mkdir /tmp/dagger && cd $_.

Créez-y un script Python dans un sous-dossier src et appelons-le src/main.py :

if random.randint(0, 1) == 0:
print("Hello world")
else:
print("Bonjour le monde!")

Ce code génère un nombre aléatoire entre 0 et 1, puis affiche soit « Hello world » soit « Bonjour le monde! » selon le résultat obtenu. C'est une simple démonstration de branchement conditionnel basé sur une valeur aléatoire.

Formidable application qui nous dit, au hasard, Hello world ou Bonjour le monde!

Nos objectifs​

Chaque fois que nous pousserons notre code vers notre outil de versioning (comme GitLab), nous voulons :

  • Lancer Pylint, Lint python scripts using Pylint - Run analyses your code without actually running it,
  • Lancer Black, Format the script using Black,
  • Lancer Mypy, Mypy is a program that will type check your Python code et
  • Lancer Ruff, an extremely fast Python linter and code formatter

(J'ai détaillé ces quatre outils dans Python - Les outils de qualité de code.)

Ces étapes sont déclenchées dans notre CI (GitLab, GitHub, ...) à chaque push et, pour faire les mêmes actions en local, nous devons créer par exemple quelques actions make (make lint, make format, ...).

Nous voulons Dagger​

Dagger

Sans surprise, à un moment il faut installer Dagger. Installer ? Aïe non ; on ne va pas l'installer car, en amoureux de Docker, nous allons utiliser Docker et créer notre image Dagger.

Créez un nouveau sous-dossier appelé .docker et, dans ce dossier, un fichier appelé Dockerfile :

# cspell:disable
FROM python:3.11-slim-buster AS base
# Dagger is using the Docker technology so we need to install Docker in the image
# Also install required Linux dependencies
# hadolint ignore=DL3008
RUN set -e -x \
&& apt-get update -yqq \
&& apt-get install -y --no-install-recommends docker.io wget \
&& apt-get clean \
&& rm -rf /tmp/* /var/lib/apt/lists/*
# Install Dagger
ARG VERSION=0.15.1
ARG ARCHIVE=dagger_v${VERSION}_linux_amd64.tar.gz
ARG URL=https://github.com/dagger/dagger/releases/download/v${VERSION}/${ARCHIVE}
# hadolint ignore=DL3003
RUN set -e -x \
&& cd /tmp \
&& wget --quiet --output-document ${ARCHIVE} ${URL} \
&& tar xvfz ${ARCHIVE} \
&& mv dagger /usr/local/bin/ \
&& rm -rf /tmp/
WORKDIR /app/src
ENTRYPOINT [ "/usr/local/bin/dagger" ]

Ce Dockerfile crée une image Docker basée sur Python 3.11 allégée, ajoute Docker et wget via le gestionnaire de paquets, puis télécharge et installe Dagger (un outil d'orchestration) dans le répertoire /usr/local/bin. L'image est configurée pour exécuter Dagger par défaut.

Il faut construire notre image, lançons donc docker build -t dagger_daemon -f .docker/Dockerfile .

Daggeriser notre application​

Comme dit plus haut, nous devons créer quelques éléments pour daggeriser notre application.

Pour cela, il faut exécuter la commande dagger init et, comme nous utilisons une image Docker où dagger est le point d'entrée, la commande à lancer est : docker run -it --rm -v /var/run/docker.sock:/var/run/docker.sock -v .:/app/src dagger_daemon init --sdk=python --source=./.pipeline

Décortiquons cette longue ligne de commande :

  • avec -it nous pourrons interagir (si besoin) avec le container et nous allouons un terminal TTY, c'est-à-dire que nous récupérons la sortie de la commande exactement comme si nous l'avions lancée sur notre machine,
  • vous devez partager votre /var/run/docker.sock local avec le container car Dagger utilise Docker-out-of-Docker (alias DooD) et, pour cette raison, le container doit pouvoir interagir avec votre instance de Docker (-v /var/run/docker.sock:/var/run/docker.sock) et
  • vous devez monter votre dossier courant local dans le container (-v .:/app/src).
  • dagger_daemon est le nom de notre image
  • init --sdk=python --source=./.pipeline est la commande Dagger à lancer

Il faudra environ deux minutes pour télécharger et initialiser Dagger (la première fois). En regardant votre système de fichiers, vous verrez, oh, que le propriétaire est root et pas vous — c'est le listing de fichiers montré plus haut.

Lancez sudo chown -R christophe:christophe . (et remplacez mon prénom par votre nom d'utilisateur Linux).

Regardons l'arborescence :

LICENSE
dagger.json
.pipeline
pyproject.toml
sdk
▼src
▼src
__init__.py
main.py

Rappelez-vous, dagger a été défini comme notre entrypoint (voir notre Dockerfile) donc, pour obtenir l'écran d'aide de Dagger, lancez simplement docker run -it --rm -v /var/run/docker.sock:/var/run/docker.sock -v .:/app/src dagger_daemon call --help — vous obtiendrez la même liste de fonctions que celle montrée en haut de cet article (cette première fois prendra plus de temps puisque Dagger doit construire le pipeline).

Plus de démos​

Créer notre fonction de linting​

Les fonctions sont définies dans le fichier .pipeline/src/src/main.py.

Ouvrez ce fichier et ajoutez une nouvelle fonction comme ci-dessous :

@function
async def lint(self, source: str) -> str:
"""
Run Pylint on the codebase.
"""
return f"\033[33mI'm inside your Dagger pipeline and I'll lint {source}\033[0m"

Cette fonction asynchrone simule l'exécution de Pylint sur une base de code en retournant un message formaté. Elle accepte une chaîne source en paramètre et renvoie un texte coloré en jaune (via les codes ANSI) indiquant qu'elle s'exécute dans un pipeline Dagger.

Enregistrez le fichier et relancez docker run -it --rm -v /var/run/docker.sock:/var/run/docker.sock -v .:/app/src dagger_daemon call --help. Regardez, nous avons notre nouvelle fonction :

>user@machine: ~/yourproject

Et nous obtenons aussi la liste des paramètres de notre fonction lint avec docker run -it --rm -v /var/run/docker.sock:/var/run/docker.sock -v .:/app/src dagger_daemon call lint --help :

>user@machine: ~/yourproject

Maintenant, retour dans .pipeline/src/src/main.py et remplacez tout le fichier (nous n'avons pas besoin des fonctions d'exemple) par ce contenu :

from dagger import dag, DefaultPath, Directory, function, object_type
from typing import Annotated
@object_type
class Src:
@function
async def lint(self, source: Annotated[Directory, DefaultPath("src")]) -> str:
"""
Run Pylint on the codebase.
"""
print(f"\033[33mI'm inside your Dagger pipeline and I'll lint {source}\033[0m")
return await (
dag.container()
.from_("python:3.13-slim")
.with_exec(["pip","install","pylint"])
.with_mounted_directory("/app/src", source)
.with_workdir("/app/src")
.with_exec(["pylint","."])
.stdout()
)

Ce code définit une classe Dagger appelée Src qui expose une fonction lint pour analyser du code Python. La fonction crée un conteneur Docker, installe pylint, monte le répertoire source fourni en paramètre, puis exécute pylint sur ce répertoire et retourne le résultat.

Nous supprimons donc les deux fonctions d'exemple et nous implémentons notre fonction de linting. Nous définissons aussi le dossier src comme dossier par défaut afin de ne plus devoir ajouter --source src à chaque appel de dagger.

En lançant docker run -it --rm -v /var/run/docker.sock:/var/run/docker.sock -v .:/app/src dagger_daemon call lint, nous demandons au pipeline de lancer notre linter (Pylint ici) sur notre dossier courant.

Comme c'est la première fois, Dagger doit faire quelques initialisations (comme installer PyLint) puis nous obtenons la sortie :

Sortie partielle - Pylint

Oui ! PyLint a fonctionné et nous a alertés d'une docstring de module manquante.

Éditez src/main.py et ajoutez une docstring de module valide (ou ignorez simplement cet avertissement) :

# pylint: disable=missing-module-docstring
import random
if random.randint(0, 1) == 0:
print("Hello world")
else:
print("Bonjour le monde!")

Ce script utilise le module random pour générer aléatoirement 0 ou 1, puis affiche un message en anglais ou en français selon le résultat. C'est un exemple simple de branchement conditionnel basé sur un tirage aléatoire.

En relançant docker run -it --rm -v /var/run/docker.sock:/var/run/docker.sock -v .:/app/src dagger_daemon call lint, nous serons félicités avec un score de 10/10.

Éditez à nouveau le fichier src/main.py, faites par exemple une faute de frappe en modifiant la ligne else: et en supprimant le : final : le linter ne sera plus content.

Information

Nous avons créé notre première tâche avec succès et nous l'avons lancée avec succès sur notre machine.

Créer un makefile​

Ça devient difficile de retenir toutes ces commandes docker xxx, non ? Simplifions cela en créant un makefile (si c'est nouveau pour vous, lisez d'abord Linux Makefile - Quand utiliser un makefile).

Grâce au makefile suivant, nous pourrons simplement lancer make build pour créer notre image Docker Dagger et make lint pour lancer la fonction lint.

Vous aurez aussi une action make bash pour entrer dans un shell interactif (tapez exit pour quitter le shell et revenir à la console de votre host). Facile, non ?

Et make help affichera l'écran d'aide de Dagger.

DAEMON_NAME=dagger_daemon
DOCKER_SOCK=-v /var/run/docker.sock:/var/run/docker.sock
.PHONY:build
build:
docker build -t ${DAEMON_NAME} -f .docker/Dockerfile .
.PHONY:bash
bash:
docker run -it --rm ${DOCKER_SOCK} -v .:/app/src --entrypoint /bin/bash ${DAEMON_NAME}
.PHONY:help
help:
docker run -it --rm ${DOCKER_SOCK} -v .:/app/src ${DAEMON_NAME} call --help
.PHONY:lint
lint:
docker run -it --rm ${DOCKER_SOCK} -v .:/app/src ${DAEMON_NAME} call lint

Ce Makefile définit des cibles pour gérer un conteneur Docker appelé dagger_daemon. Il expose des commandes pour compiler l'image Docker, lancer un shell interactif à l'intérieur du conteneur, afficher l'aide et exécuter le linting — chaque commande monte le socket Docker et le répertoire courant pour permettre au conteneur d'accéder aux ressources de l'hôte.

Formater le code avec Black​

Éditez le fichier .pipeline/src/src/main.py et ajoutez cette nouvelle fonction :

from dagger import dag, DefaultPath, Directory, function, object_type
from typing import Annotated
@object_type
class Src:
@function
async def lint(self, source: Annotated[Directory, DefaultPath("src")]) -> str:
"""
Run Pylint on the codebase.
"""
print(f"\033[33mI'm inside your Dagger pipeline and I'll lint {source}\033[0m")
return await (
dag.container()
.from_("python:3.13-slim")
.with_exec(["pip","install","pylint"])
.with_mounted_directory("/app/src", source)
.with_workdir("/app/src")
.with_exec(["pylint","."])
.stdout()
)
# highlight-start
@function
async def format(self, source: Annotated[Directory, DefaultPath("src")]) -> str:
"""
Format the codebase using Black.
"""
return await (
dag.container()
.from_("python:3.13-slim")
.with_exec(["pip","install","black"])
.with_mounted_directory("/app/src", source)
.with_workdir("/app/src")
.with_exec(["black","."])
.stdout()
)
# highlight-end

Ce code définit une classe Dagger qui encapsule des tâches de qualité de code Python. Il expose deux fonctions asynchrones : lint() qui vérifie le code avec Pylint, et format() qui formate le code avec Black. Chaque fonction crée un conteneur Docker, installe l'outil requis, monte le répertoire source, puis exécute la commande correspondante.

Donc, à partir de maintenant, vous pouvez lancer dagger call format (depuis l'intérieur du container, c'est-à-dire après un make bash) ou docker run -it --rm -v /var/run/docker.sock:/var/run/docker.sock -v .:/app/src dagger_daemon call format (depuis votre host).

Vers l'univers et l'infini​

Ok, nous avons maintenant deux tâches et il nous en reste beaucoup à implémenter. On peut faire plein de copier/coller ou prendre le temps de réfléchir :

  1. Ce serait bien de ne pas devoir spécifier à chaque fois le dossier source,
  2. Ce serait bien de lancer toutes les tâches d'un coup, en asynchrone,
  3. Ce serait bien d'avoir un dossier de configuration où stocker les fichiers de configuration

Nous devons faire une petite modification à notre image Docker :

# cspell:disable
FROM python:3.11-slim-buster AS base
# Dagger is using the Docker technology so we need to install Docker in the image
# Also install required Linux dependencies
# hadolint ignore=DL3008
RUN set -e -x \
&& apt-get update -yqq \
&& apt-get install -y --no-install-recommends docker.io wget \
&& apt-get clean \
&& rm -rf /tmp/* /var/lib/apt/lists/*
# Install Dagger
ARG VERSION=0.15.1
ARG ARCHIVE=dagger_v${VERSION}_linux_amd64.tar.gz
ARG URL=https://github.com/dagger/dagger/releases/download/v${VERSION}/${ARCHIVE}
# hadolint ignore=DL3003
RUN set -e -x \
&& cd /tmp \
&& wget --quiet --output-document ${ARCHIVE} ${URL} \
&& tar xvfz ${ARCHIVE} \
&& mv dagger /usr/local/bin/ \
&& rm -rf /tmp/
# highlight-start
# anyio is required by Python to be able to run tasks concurrently and asynchronously
RUN set -e -x \
&& pip install anyio
# highlight-end
WORKDIR /app/src
ENTRYPOINT [ "/usr/local/bin/dagger" ]

Ce Dockerfile crée une image Docker basée sur Python 3.11 et y ajoute Docker, Dagger (un outil d'orchestration), et la bibliothèque anyio pour la concurrence asynchrone. L'image est configurée pour exécuter Dagger comme point d'entrée dans le répertoire /app/src.

Cela fait, lancez make build pour créer une nouvelle image Docker avec anyio installé.

Maintenant, nous allons mettre à jour le fichier .pipeline/src/src/main.py et ajouter quelques nouvelles fonctions :

# cspell:ignore anyio,rcfile,pylintrc,workdir
import anyio
from typing import Annotated
import dagger
from dagger import DefaultPath, Doc, dag, function, object_type, Directory
@object_type
class Src:
# This is the directory where source files are located (f.i. "/app/src")
source: Annotated[
Directory,
Doc("The source folder; where the codebase is located"),
DefaultPath("src")
]
# This is the directory where configuration files are located (f.i. "/app/.config")
config: Annotated[
Directory,
Doc("The folder container configuration files"),
DefaultPath(".config")
]
@function
async def lint(self) -> str:
"""
Lint python scripts using Pylint - Run analyses your code without actually running it (https://pypi.org/project/pylint/)
"""
return await (
dag.container()
.from_("python:3.13-slim")
.with_exec(["pip","install","pylint"])
.with_mounted_directory("/app/src", self.source)
.with_mounted_directory("/app/config", self.config)
.with_workdir("/app/src")
.with_exec(["pylint",".","--rcfile","/app/config/.pylintrc"])
.stdout()
)
@function
async def format(self) -> str:
"""
Format the script using Black (https://black.readthedocs.io/en/stable/)
"""
return await (
dag.container()
.from_("python:3.13-slim")
.with_exec(["pip","install","black"])
.with_mounted_directory("/app/src", self.source)
.with_mounted_directory("/app/config", self.config)
.with_workdir("/app/src")
.with_exec(["black",".","--config","/app/config/black.toml"])
.stdout()
)
@function
async def mypy(self)-> str:
"""
Mypy is a program that will type check your Python code (https://github.com/python/mypy/)
"""
mypy_cache = dag.cache_volume("python_mypy")
return await (
dag.container()
.from_("python:3.13-slim")
.with_exec(["pip","install","mypy"])
.with_mounted_directory("/app/src", self.source)
.with_mounted_directory("/app/config", self.config)
.with_mounted_cache("/tmp/mypy", mypy_cache)
.with_workdir("/app/src")
# .with_exec(["ls","-alhR"])
.with_exec(["mypy","--config-file",f"/app/config/mypy.ini","."])
.stdout()
)
@function
async def ruff(self) -> str:
"""
An extremely fast Python linter and code formatter (https://github.com/astral-sh/ruff)
"""
return await (
dag.container()
.from_("python:3.13-slim")
.with_exec(["pip","install","ruff"])
.with_mounted_directory("/app/src", self.source)
.with_mounted_directory("/app/config", self.config)
.with_workdir("/app/src")
.with_exec(["ruff","check","--config","/app/config/pyproject.toml", "."])
.with_exec(["ruff","format","--config","/app/config/pyproject.toml", "."])
.stdout()
)
@function
async def run_all(self):
"""Run linter, type-checker, unit tests concurrently"""
async with anyio.create_task_group() as tg:
tg.start_soon(self.ruff)
tg.start_soon(self.lint)
tg.start_soon(self.format)
tg.start_soon(self.mypy)

Ce fichier définit une classe Src qui encapsule des opérations de linting et formatage Python dans des conteneurs isolés avec Dagger. Chaque méthode crée un conteneur Python, installe un outil spécifique (Pylint, Black, Mypy ou Ruff), monte les répertoires source et config, puis exécute l'outil et retourne son résultat. La méthode run_all exécute tous ces outils en parallèle pour un contrôle de qualité complet.

Ce nouveau fichier apporte beaucoup de changements :

Nous avons défini deux variables globales appelées source et config. Désormais, nous ne passons plus le dossier source à la fonction lint (paramètre local) mais il suffit de le définir une seule fois (paramètre global). Nous avons aussi ajouté un dossier config pour pouvoir indiquer à Dagger où sont stockés nos fichiers de configuration.

source: Annotated[
Directory,
Doc("The source folder; where the codebase is located"),
DefaultPath("src")
]
config: Annotated[
Directory,
Doc("The folder container configuration files"),
DefaultPath(".config")
]

Ce snippet définit deux variables annotées qui décrivent des chemins de répertoires : un dossier source où le code se trouve et un dossier de configuration pour les fichiers de configuration. Chaque variable utilise des annotations pour spécifier son type, sa documentation et son chemin par défaut.

Nous avons deux nouvelles fonctions appelées mypy et ruff et une dernière appelée run_all :

@function
async def run_all(self):
"""Run linter, type-checker, unit tests concurrently"""
async with anyio.create_task_group() as tg:
tg.start_soon(self.ruff)
tg.start_soon(self.lint)
tg.start_soon(self.format)
tg.start_soon(self.mypy)

Cette méthode lance quatre tâches asynchrones en parallèle — ruff, lint, format et mypy — en utilisant un gestionnaire de groupe de tâches pour attendre que toutes se terminent. C'est un moyen efficace d'exécuter plusieurs outils de qualité de code simultanément au lieu de les faire s'attendre les uns après les autres.

Celle-ci démarre les quatre fonctions en parallèle et attend qu'une échoue ou que les quatre réussissent.

Sous le capot (passez votre chemin si vous voulez juste l'utiliser)​

Fichiers de configuration​

Avant de lancer ces nouvelles fonctions, nous devons créer quelques fichiers de configuration.

[MASTER]
disable=
broad-exception-caught,
consider-using-assignment-expr,
invalid-name,
missing-module-docstring,
too-few-public-methods,
too-many-instance-attributes,
too-many-positional-arguments
; Pickle collected data for later comparisons.
persistent=yes
; List of plugins (as comma-separated values of python modules names) to load,
; usually to register additional checkers.
load-plugins=
pylint.extensions.check_elif,
pylint.extensions.bad_builtin,
pylint.extensions.docparams,
pylint.extensions.for_any_all,
pylint.extensions.set_membership,
pylint.extensions.code_style,
pylint.extensions.overlapping_exceptions,
pylint.extensions.typing,
pylint.extensions.redefined_variable_type,
pylint.extensions.comparison_placement,
; pylint.extensions.mccabe,
; Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
; number of processors available to use.
jobs=0
; When enabled, pylint would attempt to guess common misconfiguration and emit
; user-friendly hints instead of false-positive error messages.
suggestion-mode=yes
; Minimum supported python version
py-version = 3.13.0
; Specify a score threshold to be exceeded before program exits with error.
fail-under=9.50
[FORMAT]
; The same as in prospector.yaml, .pylintrc, black.toml and pyproject.toml
max-line-length=120

Ceci est un fichier de configuration pour Pylint, l'outil d'analyse statique de code Python. Il désactive certaines vérifications trop strictes, active des extensions supplémentaires pour des contrôles avancés, configure l'analyse parallèle, et fixe des paramètres comme la version Python minimale et la longueur maximale des lignes.

[tool.black]
# The same as in prospector.yaml, .pylintrc, black.toml and pyproject.toml
line-length = 120
# We'll format the code for Python 3.13.0; we can add multiple versions (the ones supported by
# the script, all versions separated by a comma like in ['py312', 'py313'] (run "black --help" to get them)
target-version = ['py313']

Cette section configure l'outil Black, un formateur de code Python. Elle définit la longueur maximale des lignes à 120 caractères et spécifie que le code doit être formaté pour Python 3.13, ce qui garantit la compatibilité avec cette version tout en appliquant les conventions de formatage appropriées.

[mypy]
show_error_codes = true
; First we turn on *all the checks*, and then we turn off those that are too annoying.
strict = True
; Don't know why but mypy returns errors like
; Customer? has no attribute "input_path" [attr-defined]
; when that attribute is well present. Ignore the error temporarily
disable_error_code = attr-defined
cache_dir = /tmp/mypy
; Disallows defining functions without type annotations or with incomplete type annotations
disallow_untyped_defs = true
no_implicit_optional = true
strict_equality = true
warn_redundant_casts = true
warn_unused_ignores = true
python_version = 3.13

Ce fichier configure mypy, l'outil de vérification de types pour Python. Il active le mode strict pour détecter les erreurs de typage, puis désactive une vérification spécifique qui génère des faux positifs sur les attributs. Il configure également mypy pour exiger des annotations de types complètes, interdire les optionnels implicites, et cible Python 3.13.

[tool.ruff]
exclude=[]
# The same as in prospector.yaml, .pylintrc, black.toml and pyproject.toml
line-length = 120
indent-width = 4

Ce fichier configure l'outil Ruff, un linter et formateur Python rapide. Il désactive les exclusions par défaut, définit une longueur de ligne à 120 caractères et une indentation de 4 espaces, alignant ainsi la configuration avec d'autres outils de contrôle de qualité du projet.

Implémenter une CI GitLab avec Dagger​

Revenons à notre objectif : simplifier le processus de pipeline à la fois au niveau de la CI (GitLab, GitHub, etc.) et en local.

Nous venons de faire la partie locale, attaquons la CI distante.

Pour les chapitres suivants, je suppose que vous utilisez un serveur GitLab auto-hébergé.

Autoriser le runner GitLab à accéder à Docker​

Je ne suis pas expert en configuration de runner GitLab mais la configuration suivante fonctionne pour moi. J'ai détaillé cette mise en place, et le piège du partage de dossiers qui va avec, dans GitLab - Exécuter Docker-out-of-Docker dans votre CI.

Connectez-vous en SSH à votre serveur de runner GitLab et éditez le fichier /etc/gitlab-runner/config.toml (vous devez être root). Ajoutez simplement /var/run/docker.sock:/var/run/docker.sock à la propriété volumes :

[runners.docker]
# Adding /var/run/docker.sock:/var/run/docker.sock
# will allow to use commands like "docker image list" i.e. running Docker commands
# in CI scripts. This is called "Docker socket binding".
volumes = ["/cache", "/var/run/docker.sock:/var/run/docker.sock"]

Cette configuration TOML définit les volumes montés pour les runners Docker utilisés en intégration continue. Elle expose deux chemins : /cache pour stocker des données persistantes entre exécutions, et /var/run/docker.sock pour permettre aux scripts CI d'exécuter des commandes Docker directement à l'intérieur des conteneurs.

Assurez-vous aussi que l'utilisateur Linux utilisé par votre runner GitLab (le nom par défaut est gitlab-runner) fait partie du groupe docker. Cela se fait en lançant sudo usermod -aG docker gitlab-runner en CLI (voir https://docs.gitlab.com/ee/ci/docker/using_docker_build.html#use-the-shell-executor).

Information

Documentation officielle de GitLab à propos des volumes.

Pour vérifier que ça marche, lancez sudo su gitlab-runner pour basculer sur cet utilisateur puis lancez docker info et docker image list et regardez si ça fonctionne. Si oui, votre utilisateur fait bien partie du groupe Docker.

Configurer votre CI​

Créez un repository GitLab et poussez-y votre projet existant.

Maintenant, créez un fichier appelé .gitlab-ci.yml avec ce contenu :

.docker:
image: docker:latest
services:
- docker:${DOCKER_VERSION}-dind
variables:
DOCKER_HOST: unix:///var/run/docker.sock # Docker Socket Binding
.dagger:
extends: [.docker]
before_script:
- apk add curl
- curl -fsSL https://dl.dagger.io/dagger/install.sh | BIN_DIR=/usr/local/bin sh
lint:
extends: [.dagger]
script:
- dagger call --source src --config .config lint
format:
extends: [.dagger]
script:
- dagger call --source src --config .config format
mypy:
extends: [.dagger]
script:
- dagger call --source src --config .config mypy
ruff:
extends: [.dagger]
script:
- dagger call --source src --config .config ruff

Ce fichier de configuration GitLab CI définit des modèles réutilisables pour exécuter des outils de qualité de code (lint, format, mypy, ruff) via Dagger dans des conteneurs Docker. Le modèle .docker configure l'accès à Docker en tant que service, le modèle .dagger l'étend et installe Dagger, puis quatre jobs héritent de .dagger pour lancer chacun un outil de vérification spécifique.

Et poussez les changements vers GitLab. La présence du fichier .gitlab-ci.yml indiquera à GitLab d'instancier un pipeline après chaque commit et, dans notre exemple, de lancer les quatre jobs.

Docker Socket Binding

L'exemple fourni utilise la technique appelée Docker Socket Binding : nous n'avons pas besoin de définir la variable DOCKER_HOST comme on peut le voir dans la documentation officielle de Dagger. En effet, si elle n'est pas spécifiée, DOCKER_HOST vaut unix:///var/run/docker.sock (doc).

Comme nous avons partagé le daemon Docker (/var/run/docker.sock) dans notre fichier de configuration GitLab /etc/gitlab-runner/config.toml, nous avons autorisé la CI à accéder au socket.

Mais vous pouvez aussi utiliser le mode asynchrone puisque nous avons implémenté une fonctionnalité run-all :

.docker:
image: docker:latest
services:
- docker:${DOCKER_VERSION}-dind
variables:
DOCKER_HOST: unix:///var/run/docker.sock # Docker Socket Binding
.dagger:
extends: [.docker]
before_script:
- apk add curl
- curl -fsSL https://dl.dagger.io/dagger/install.sh | BIN_DIR=/usr/local/bin sh
run-all:
extends: [.dagger]
script:
- dagger call --source src --config .config run-all

Ce fichier définit des configurations réutilisables pour un pipeline CI/CD utilisant Docker et Dagger. La section .docker configure l'accès à Docker, .dagger l'étend en ajoutant l'installation de Dagger, et run-all utilise cette configuration pour exécuter un job qui lance des commandes Dagger.

Conclusion​

Nous avons construit une image Docker Dagger sur mesure, daggerisé un projet existant, et défini quatre fonctions (lint, format, mypy et ruff) plus une fonction run-all pour toutes les lancer en parallèle. Les actions make nous évitent de retenir les longues commandes du CLI Docker, et exactement les mêmes fonctions tournent maintenant aussi dans une CI GitLab, en partageant le socket Docker avec le runner.

La boucle push → attendre → lire le log → deviner → repousser de l'introduction a disparu : tout ce qui se déclenche en CI peut d'abord être joué et débogué en local, en quelques secondes, et non en minutes sur un serveur distant.

Si ce n'est pas déjà fait, lisez Python - Les outils de qualité de code pour plus de détails sur Pylint, Black, mypy et Ruff eux-mêmes.


Cet article vous a-t-il été utile ?
Avez-vous suivi les étapes de cet article ?