Mais qu’est-ce que Dagger ?#
Dagger est une boîte à outils (SDK) open source permettant de construire des chaînes CI/CD à base de conteneurs. Il est distribué avec la licence Apache Version 2.0 en ce qui concerne le moteur Dagger qui représente le cœur de l’outil.
Grâce à la technologie de conteneurisation, et c’est certainement le plus gros avantage de Dagger: vous pouvez tester en local vos pipelines et oublier le fameux push and pray.
C’est un problème auquel je suis souvent confronté, mais j’imagine que vous aussi ! Qui n’a jamais eu une erreur de YAML invalide suite à la modification de son .gitlab-ci.yml
ou d’un fichier au sein de l’arborescence .github/workflows
?
Et c’est le souci que Dagger tente de résoudre avec cette approche, vous pouvez tester vos modifications en local, ajouter de nouvelles étapes, puis les déployer sur votre outil de référence comme GitLab CI, GitHub Actions, Circle CI, etc. et même Kubernetes !
La technologie à base de conteneurs, que ce soit avec Docker, Podman ou d’autres, vous garantie une réutilisabilité et consistence d’exécution peu importe la plateforme cible en favorisant la collaboration grâce à une prise en main assez facile du code.
De plus, Dagger propose une configuration à base de trois langages principaux : Python, Go et Typescript. D’autres sont encore en phase expérimentale comme le .Net, Java, Rust, PHP, etc. L’idée étant de choisir le langage de votre application pour éviter d’avoir différentes bases de code et ainsi, se passer de la structure parfois complexe du YAML.
Point important qui peut freiner l’adoption de ce type d’outil : il est nécessaire, pour le moment en tout cas, d’exécuter le conteneur principal, le moteur Dagger avec l’utilisateur root.
Pour ma part, j’ai opté pour le Python, langage avec lequel je suis le plus à l’aise notamment pour élaborer des scripts rapides à mettre en place. N’étant pas un développeur chevronné de Python, j’ai trouvé la documentation extrêmement bien fournie sur les trois langages principaux pour me guider à concevoir ma première pipeline.
Fonctionnement#
Dagger se base sur l’appel de Dagger Functions, il en existe deux types :
Celles que vous pouvez créer. Ces fonctions sont représentées par l’annotation @function
en Python et elles peuvent être appelées par la commande dagger call
.
Sans oublier les Dagger Functions dites Core que vous pouvez chaîner pour exécuter vos tâches. Celles-ci sont fournies par le SDK Dagger.
Par exemple, ce bloc de code ci-dessous permettra de créer un conteneur en récupérant une image nginx:1.25-alpine
, de monter un répertoire au sein de /usr/share/nginx/html
et en exposant le port 80.
dag.container()
.from_("nginx:1.25-alpine")
.with_directory("/usr/share/nginx/html", build)
.with_exposed_port(80)
J’ai donc utilisé plusieurs fonctions comme from_
, with_directory
et with_exposed_port
qui sont répertoriées comme des Dagger Functions Core.
Le SDK Python regorge d’informations sur les différentes API à appeler, car oui, derrière ces fonctions, le moteur au sein de Dagger utilise du GraphQL pour les fonctionnalités de base (Core) comme exécuter des conteneurs, interagir avec des fichiers ou dossiers, etc. Ce qui a été décrit juste au-dessus.
Chaque requête API utilise le Directed Acyclic Graph (DAG) pour calculer le résultat en optimisant les performances grâce à la mise en cache des opérations. Cette mise en cache est également un atout avancé par les créateurs de l’outil pour exécuter très rapidement vos pipelines et donc économiser de précieuses secondes d’exécution.
Si vous souhaitez connaître l’architecture de Dagger, je vous recommande de consulter cette page qui fournie une vue d’ensemble de l’outil.
En guise d’introduction, je vous conseille de réaliser le Quickstart pour mettre le pied à l’étrier dans le langage de votre choix sans oublier d’installer le CLI au préalable.
Toutes les fonctionnalités de Dagger sont répertoriées dans le Cookbook, c’est notamment là où j’ai trouvé beaucoup de fonctionnalités intéressantes comme monter un secret en tant que fichier ou invalider le cache pour différentes opérations. Ces points seront détaillés un peu plus tard dans l’article.
Pour éviter de réinventer la roue en permanence, vous pouvez consulter le Daggerverse qui indexe les modules distribués de manière publique et qui sont totalement réutilisables.
Enfin, pour ceux qui souhaitent visualiser et debugger les pipelines définies dans Dagger, un service Dagger Cloud est disponible, avec la possibilité d’avoir une offre gratuite pour les particuliers.
Ma première pipeline#
Les choses sérieuses commencent !
J’ai eu l’idée de mettre Dagger en place sur un cas que j’utilise presque tous les jours : le déploiement d’infrastructure as code et particulièrement avec OpenTofu et Google Cloud.
L’idée sera de reproduire la structure de cette chaîne CI/CD adaptée à OpenTofu en créant les différentes fonctions au sein de Dagger.
Vous pouvez récupérer le code sur GitHub et l’adapter en fonction de vos besoins :
A Dagger pipeline with OpenTofu code !
Structure du code OpenTofu#
Pour le code d’infrastructure, rien de bien sorcier. Celui-ci est situé dans le dossier infra
et contient deux dossiers, le premier modules
permet de centraliser le module de nommage des ressources. Tandis que le second est la couche (layer) network.
Cette couche network déploie un VPC et un sous-réseau au sein de Google Cloud. Deux configurations sont à définir :
- Un premier fichier
.tfvars
au sein du dossierconfigurations
pour instancier la couche choisie et qui possède les variables obligatoires commesubnets
, pour définir les sous-réseaux; - La configuration du backend associée au fichier du dessus dans le dossier
backend-configurations
, le fichier doit posséder le suffix-backend
.
Exemple au sein du dépôt de code avec le fichier example.tfvars
:
project_id = "" # À modifier
project_name = "example"
environment_type = "dev"
location = "europe-west6"
subnets = [
{
name = "frontend"
ip_cidr_range = "10.0.0.0/24"
},
{
name = "backend"
ip_cidr_range = "10.0.1.0/24"
},
]
et example-backend.tfvars
:
bucket = "" # À modifier
prefix = "opentofu/tfstates/network/example"
Ces fichiers sont totalement ajustables en fonction de vos besoins et demande d’avoir au préalable un bucket sur Google Cloud Storage en tant que backend ainsi qu’un project_id
et un compte de service Google Cloud avec des permissions pour créer les ressources réseaux citées au-dessus.
Le rôle roles/compute.networkAdmin
sera largement suffisant en ce qui concerne le compte de service au niveau projet.
Vue d’ensemble du code Dagger#
Le code spécifique de Dagger est situé dans le répertoire dagger/src/main
, on y retrouve quatre fichiers :
__init__.py
: définit le point d’entrée pour Dagger ;tofu_pipeline.py
: contient les Dagger Functions personnalisées à appeler, à savoirdeploy
oudestroy
;tofu_context.py
: permet de stocker le contexte d’exécution d’OpenTofu notamment les différents arguments qui seront utilisés lors de l’appel de la commandetofu
;tofu_jobs.py
: définit l’ensemble des jobs unitaires qui seront appelés à tour de rôle comme les commandesfmt
,validate
,plan
,apply
,destroy
, etc. Cela contient aussi la définition d’exécution de Tflint et Checkov.
Pour invoquer la Dagger Function deploy
, vous pouvez utiliser la commande dagger call
comme ceci :
dagger call deploy \
--directory=./infra --layer=network --configuration=example --credentials=file:./google_application_credentials.json --tofu-version=1.8.3 --checkov-version=3.2.257 --tflint-version=v0.53.0
En effet, plusieurs arguments sont requis :
--directory
: Chemin du dossier dans lequel se trouve l’infrastructure as code,./infra
par exemple ;--layer
: Nom du répertoire d’infrastructure à déployer ;--configuration
: Nom de la configuration (sous forme de fichier.tfvars
) à l’intérieur du dossier défini au-dessus ;--credentials
: Chemin du fichier JSON pour le compte de service Google Cloud. Par exemple :file:./google_application_credentials.json
. Le préfixefile:
est obligatoire pour monter en contenu en tant que fichier ;--tofu-version
: Version d’OpenTofu (valeur par défaut :latest
) ;--checkov-version
: Version de Checkov (valeur par défaut :latest
) ;--tflint-version
: Version de Tflint (valeur par défaut :latest
).
Les mains dans le code#
Au sein du fichier tofu_jobs.py
, la fonction _tofu
sert de base pour créer le conteneur qui exécutera l’ensemble des actions d’OpenTofu (sauf pour Checkov et Tflint qui utilisent des images différentes) :
def _tofu(self) -> dagger.Container:
"""Return a configured container for OpenTofu"""
return (
dag.container()
.from_(f"ghcr.io/opentofu/opentofu:{self._tofu_context.tofu_version}")
.with_directory("/infra", self._tofu_context.directory)
.with_mounted_cache(
f"/infra/{self._tofu_context.layer}/.terraform",
dag.cache_volume("terraform"),
sharing=dagger.CacheSharingMode.SHARED
)
.with_mounted_cache(
"/infra/plan",
dag.cache_volume("plan"),
sharing=dagger.CacheSharingMode.PRIVATE
)
.with_workdir(f"/infra/{self._tofu_context.layer}")
)
Voici quelques explications du code, tout d’abord on commence par la récupération de l’image en fonction de la version définie au sein du contexte d’exécution :
dag.container()
.from_(f"ghcr.io/opentofu/opentofu:{self._tofu_context.tofu_version}")
Comme pour des conteneurs classiques, il est possible de monter des volumes, notamment pour le code source. Pour cela, on utilise la fonction with_directory
:
.with_directory("/infra", self._tofu_context.directory)
OpenTofu aura besoin de stocker les providers et modules, c’est pourquoi il convient de monter un système de cache partagé pour réutiliser les binaires sur différentes exécutions peu importe les paramètres, d’où le dagger.CacheSharingMode.SHARED
:
.with_mounted_cache(
f"/infra/{self._tofu_context.layer}/.terraform",
dag.cache_volume("terraform"),
sharing=dagger.CacheSharingMode.SHARED
)
On fait la même chose dans un répertoire pour stocker le plan, mais on désactive le partage avec dagger.CacheSharingMode.PRIVATE
. En effet, chaque plan est propre au contexte d’exécution d’OpenTofu.
Enfin pour cette partie, on positionne le répertoire de travail en fonction de la couche d’infrastructure définie au sein du contexte :
.with_workdir(f"/infra/{self._tofu_context.layer}")
Vous l’aurez compris, cette fonction sert de socle pour les différentes exécutions de la commande tofu
, je ne rentre pas dans les détails pour les fonctions checkov
ou tflint
, le fonctionnement est similaire.
Elle est utilisée par les fonctions format
et init
comme ceci :
async def format(self) -> str:
"""Check the code formatting"""
return await (
self._tofu()
.with_exec([
"tofu",
"fmt",
"-check",
"-recursive",
"-write=false",
"-diff",
]).stdout()
)
Le self._tofu()
appelle la méthode du dessus et permet de chaîner d’autres Dagger Functions car _tofu
renvoie un conteneur (dagger.Container
).
L’instruction with_exec
utilise la commande et l’ensemble des arguments pour lancer la vérification du formatage du code.
Pour finir, les fonctions checkov
et tflint
reposent sur leurs fichiers de configuration respectifs à savoir .checkov.yml
et .tflint.hcl
. Ces fichiers doivent se trouver à la racine du dépôt de code comme c’est le cas dans l’exemple fourni.
Invalider le cache, le point important#
Quand on fait de l’infrastructure as code, on souhaite avoir un plan à chaque exécution même si le code source n’est pas modifié. Notamment car l’infrastructure peut être modifiée à cause d’actions manuelles même si c’est une très mauvaise pratique…
C’est pourquoi, il est nécessaire de contrer le système de cache de Dagger en procédant à une invalidation de celui-ci.
C’est ce que l’on retrouve avec la fonction plan
:
.with_env_variable("CACHEBUSTER", str(datetime.now()))
Cette méthode décrite dans la documentation permet de forcer Dagger à rejouer l’exécution des commandes au sein du conteneur.
Gestion des secrets#
Dans la fonction _init
qui s’occupe de réaliser la commande tofu init
, le fichier contenant le compte de service pour se connecter à Google Cloud doit être monté au sein du conteneur.
Pour cela, le cookbook donne un exemple a utiliser avec la Dagger Function with_mounted_secret
:
def _init(self) -> dagger.Container:
"""Initialise OpenTofu layer"""
return (
self._tofu()
.with_mounted_secret("/infra/credentials", self._tofu_context.credentials)
[...]
Cette méthode protège les informations sensibles sans être obligé de stocker en dur ce type d’information.
Exécution en local#
Tout est bon ?
Le temps du déploiement du code OpenTofu est venu !
Pour cela, rien de plus simple avec la commande dagger call
:
dagger call deploy --directory=./infra --layer=network --configuration=example --credentials=file:./google_application_credentials.json --tofu-version=1.8.3 --checkov-version=3.2.257 --tflint-version=v0.53.0 --progress=plain
--progress=plain
n’est pas obligatoire mais permet d’avoir un affichage plus clair pour suivre ce qu’il se passe au sein de votre pipeline.Voici un exemple de log :
[...]
15 : TofuPipeline.deploy(
15 : checkovVersion: "3.2.257"
15 : configuration: "example"
15 : credentials: setSecret(name: "a4c62b237ad8da55664161b75ebdb135eed885b818e20c0ec5573e0cd963d1f1"): Secret!
15 : directory: ModuleSource.resolveDirectoryFromCaller(path: "./infra"): Directory!
15 : layer: "network"
15 : tflintVersion: "v0.53.0"
15 : tofuVersion: "1.8.3"
15 : ): Void
16 : upload /Users/axinorm/dev/perso/dagger/tofu-pipeline from 3c29abyrf1rwefjc5ftbxz1k4 (client id: vmt19b2jf3z6x2m83s7crt84u, session id: p7zj7atp82diiggzwyaigavbw)
16 : upload /Users/axinorm/dev/perso/dagger/tofu-pipeline from 3c29abyrf1rwefjc5ftbxz1k4 (client id: vmt19b2jf3z6x2m83s7crt84u, session id: p7zj7atp82diiggzwyaigavbw) DONE [0.2s]
17 : Container.from(address: "ghcr.io/opentofu/opentofu:1.8.3"): Container!
18 : resolving ghcr.io/opentofu/opentofu:1.8.3
18 : resolving ghcr.io/opentofu/opentofu:1.8.3 DONE [1.1s]
19 : cache request: pull ghcr.io/opentofu/opentofu:1.8.3
20 : Container.withDirectory(
20 : directory: ModuleSource.resolveDirectoryFromCaller(path: "./infra"): Directory!
20 : exclude: []
20 : include: []
20 : path: "/infra"
20 : ): Container!
21 : copy / /infra
17 : Container.from DONE [1.1s]
22 : Container.withMountedCache(
22 : cache: cacheVolume(key: "terraform"): CacheVolume!
22 : path: "/infra/network/.terraform"
22 : ): Container!
22 : Container.withMountedCache DONE [0.0s]
23 : Container.withMountedCache(
23 : cache: cacheVolume(key: "plan"): CacheVolume!
23 : path: "/infra/plan"
23 : sharing: PRIVATE
23 : ): Container!
23 : Container.withMountedCache DONE [0.0s]
24 : Container.withWorkdir(path: "/infra/network"): Container!
24 : Container.withWorkdir DONE [0.0s]
25 : Container.withExec(args: ["tofu", "fmt", "-check", "-recursive", "-write=false", "-diff"]): Container!
25 : Container.withExec DONE [0.0s]
26 : Container.stdout: String!
26 : Container.stdout DONE [5.7s]
27 : Container.withExec(args: ["tofu", "init", "-backend-config", "backend-configurations/example-backend.tfvars"]): Container!
28 : Container.withExec(args: ["tofu", "validate"]): Container!
28 : Container.withExec DONE [0.0s]
29 : Container.stdout: String!
27 : Container.withExec(args: ["tofu", "init", "-backend-config", "backend-configurations/example-backend.tfvars"]): Container!
27 : [0.2s] |
27 : [0.2s] | Initializing the backend...
27 : [0.6s] |
27 : [0.6s] | Successfully configured the backend "gcs"! OpenTofu will automatically
27 : [0.6s] | use this backend unless the backend configuration changes.
27 : [0.8s] | Initializing modules...
27 : [0.8s] | - naming in ../modules/naming
27 : [0.8s] |
27 : [0.8s] | Initializing provider plugins...
27 : [0.8s] | - Finding hashicorp/google versions matching "~> 6.5.0"...
27 : [2.8s] | - Installing hashicorp/google v6.5.0...
27 : [7.0s] | - Installed hashicorp/google v6.5.0 (signed, key ID 0C0AF313E5FD9F80)
27 : [7.0s] |
[...]
27 : Container.withExec DONE [0.0s]
28 : Container.withExec DONE [9.0s]
28 : [8.8s] | Success! The configuration is valid.
29 : Container.stdout DONE [8.9s]
30 : Container.from(address: "ghcr.io/terraform-linters/tflint:v0.53.0"): Container!
31 : resolving ghcr.io/terraform-linters/tflint:v0.53.0
31 : resolving ghcr.io/terraform-linters/tflint:v0.53.0 DONE [1.0s]
30 : Container.from DONE [1.0s]
32 : Container.stdout: String!
30 : Container.from(address: "ghcr.io/terraform-linters/tflint:v0.53.0"): Container!
33 : pull ghcr.io/terraform-linters/tflint:v0.53.0
34 : Container.withDirectory(
34 : directory: ModuleSource.resolveDirectoryFromCaller(path: "./infra"): Directory!
34 : exclude: []
34 : include: []
34 : path: "/infra"
34 : ): Container!
34 : Container.withDirectory DONE [0.0s]
35 : Container.withFile(
35 : path: "/infra/network/.tflint.hcl"
35 : source: Directory.file(path: ".tflint.hcl"): File!
35 : ): Container!
35 : Container.withFile DONE [0.0s]
36 : Container.withWorkdir(path: "/infra/network"): Container!
36 : Container.withWorkdir DONE [0.0s]
37 : Container.withExec(args: ["tflint", "--recursive"]): Container!
37 : Container.withExec DONE [0.0s]
30 : Container.from(address: "ghcr.io/terraform-linters/tflint:v0.53.0"): Container!
33 : pull ghcr.io/terraform-linters/tflint:v0.53.0 DONE [0.1s]
32 : Container.stdout DONE [2.6s]
38 : Container.from(address: "bridgecrew/checkov:3.2.257"): Container!
39 : resolving docker.io/bridgecrew/checkov:3.2.257
39 : resolving docker.io/bridgecrew/checkov:3.2.257 DONE [1.4s]
40 : pull docker.io/bridgecrew/checkov:3.2.257
41 : Container.withDirectory(
41 : directory: ModuleSource.resolveDirectoryFromCaller(path: "./infra"): Directory!
41 : exclude: []
41 : include: []
41 : path: "/infra"
41 : ): Container!
41 : Container.withDirectory DONE [0.0s]
42 : Container.withFile(
42 : path: "/infra/network/.checkov.yaml"
42 : source: Directory.file(path: ".checkov.yaml"): File!
42 : ): Container!
43 : copy /.checkov.yaml /infra/network/.checkov.yaml
38 : Container.from(address: "bridgecrew/checkov:3.2.257"): Container!
40 : pull docker.io/bridgecrew/checkov:3.2.257 DONE [0.1s]
38 : Container.from DONE [1.4s]
42 : Container.withFile DONE [0.0s]
44 : Container.withWorkdir(path: "/infra/network"): Container!
44 : Container.withWorkdir DONE [0.0s]
45 : Container.withExec(args: ["checkov", "-d", "."]): Container!
45 : Container.withExec DONE [0.0s]
46 : Container.stdout: String!
45 : Container.withExec DONE [27.6s]
45 : [27.4s] | terraform scan results:
45 : [27.4s] |
45 : [27.4s] | Passed checks: 0, Failed checks: 0, Skipped checks: 4
45 : [27.4s] |
45 : [27.4s] |
46 : Container.stdout DONE [29.9s]
47 : Container.withExec(args: ["tofu", "plan", "-var-file", "./configurations/example.tfvars", "-out", "/infra/plan/example.tofuplan"]): Container!
48 : Container.stdout: String!
47 : Container.withExec(args: ["tofu", "plan", "-var-file", "./configurations/example.tfvars", "-out", "/infra/plan/example.tofuplan"]): Container!
47 : [3.0s] |
47 : [3.0s] | OpenTofu used the selected providers to generate the following execution
47 : [3.0s] | plan. Resource actions are indicated with the following symbols:
47 : [3.0s] | + create
47 : [3.0s] |
47 : [3.0s] | OpenTofu will perform the following actions:
47 : [3.0s] |
47 : [3.0s] | # google_compute_network.this will be created
47 : [3.0s] | + resource "google_compute_network" "this" {
47 : [3.0s] | + auto_create_subnetworks = false
47 : [3.0s] | + delete_default_routes_on_create = false
47 : [3.0s] | + gateway_ipv4 = (known after apply)
47 : [3.0s] | + id = (known after apply)
47 : [3.0s] | + internal_ipv6_range = (known after apply)
47 : [3.0s] | + mtu = (known after apply)
47 : [3.0s] | + name = "vpc-example-ew6"
47 : [3.0s] | + network_firewall_policy_enforcement_order = "AFTER_CLASSIC_FIREWALL"
47 : [3.0s] | + numeric_id = (known after apply)
47 : [3.0s] | + project = "..."
47 : [3.0s] | + routing_mode = (known after apply)
47 : [3.0s] | + self_link = (known after apply)
47 : [3.0s] | }
[...]
À la fin, tout est correctement déployé ! Cela rend visible l’ensemble des actions effectuées, du fmt
au plan
, j’ai enlevé l’apply
pour raccourcir les logs mais vous voyez le principe.
Exécution sur GitHub Actions#
Une fois l’exécution en local validée, on peut s’attaquer au déploiement de notre pipeline Dagger sur GitHub Actions par exemple.
Vous pouvez récupérer le contenu et créer un fichier dagger.yml
au sein du répertoire .github/workflows
.
Il s’inspire bien évidemment de celui-ci de la documentation en ajoutant la partie pour créer le secret contenant le compte de service Google Cloud.
name: dagger
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Create secret file with Google Cloud credentials
run: |
cat << EOF > ./google_application_credentials.json
${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }}
EOF
- name: Deploy infrastructure
uses: dagger/dagger-for-github@v6
with:
version: 0.13.5
dagger-flags: --progress=plain
verb: call
workdir: .
module: ""
args: deploy --directory=./infra --layer=$LAYER --configuration=$CONFIGURATION --credentials=file:./google_application_credentials.json --tofu-version=$TOFU_VERSION --checkov-version=$CHECKOV_VERSION --tflint-version=$TFLINT_VERSION
env:
LAYER: ${{ vars.LAYER }}
CONFIGURATION: ${{ vars.CONFIGURATION }}
TOFU_VERSION: ${{ vars.TOFU_VERSION }}
CHECKOV_VERSION: ${{ vars.CHECKOV_VERSION }}
TFLINT_VERSION: ${{ vars.TFLINT_VERSION }}
N’oubliez pas de définir les variables LAYER
, CONFIGURATION
, TOFU_VERSION
, CHECKOV_VERSION
et TFLINT_VERSION
au sein des variables de GitHub Actions en précisant bien GOOGLE_APPLICATION_CREDENTIALS
dans la partie secret.
Conclusion#
Dagger se distingue par son approche basée sur les conteneurs permettant d’exécuter vos pipelines aussi bien en local ou dans votre outil préféré. La prise en main est facilitée par la documentation mais nécessite d’utiliser un langage compatible avec le SDK ou de dialoguer avec les APIs disponibles en GraphQL.
Je trouve personnellement que Dagger est très prometteur dans son concept, cela permet à chaque développeur de vérifier son code dans des conditions standardisées à travers les Dagger Functions définies sans dépendre d’outils tiers avant le fameux git push
.
D’un point de vue développeur applicatif, chaque outil de CI/CD ayant sa propre nomenclature et son propre format, Dagger permet d’uniformiser tout cela en masquant une complexité d’adaptation et rendant les migrations plus faciles d’un outil à l’autre.
Comme évoqué en début, le fait d’être obligé d’exécuter Dagger en mode privilégié est un point de sécurité important à considérer, surtout quand on dispose de ses propres runners. J’espère que les futures versions proposeront une alternative sur ce sujet précis.
Comme à chaque fois, il faut voir en fonction de votre contexte, mais c’est un outil proposant une démarche innovante, même pour de l’infrastructure as code !