
Un module réutilisable se reconnaît à ce qu'il ne décide pas à la place de son appelant. Il ne configure pas son provider, il reçoit ses dépendances au lieu de les fabriquer, et il documente son contrat. Ces trois règles ne relèvent pas du goût : elles ont des conséquences mesurables, et un module qui les ignore finit par être impossible à détruire proprement.
Tout ce qui suit a été exécuté sur Terraform v1.15.4 : messages d'erreur,
avertissements et contenu du JSON du plan compris. Deux formulations répandues n'y
survivent pas, celle du bloc provider interdit pour des raisons de confort,
et celle de l'héritage automatique d'une configuration de provider.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Pourquoi un module ne contient pas de bloc
provider, et ce qui casse sinon - Comment lui passer une configuration aliasée, des deux côtés
- L'inversion de dépendance, cœur de la page officielle sur la composition
- Pourquoi l'arbre des modules reste plat
- Où lire la preuve de chaque pratique, sans relire le code
Prérequis
Section intitulée « Prérequis »- Terraform 1.11 ou plus récent (installer Terraform).
- Savoir créer un module et connaître le socle de fichiers standard.
- Utile en complément : tester un module, qui prouve que le contrat tient, et versionner ses modules, qui le publie sans casser les consommateurs.
Un module ne configure pas son provider
Section intitulée « Un module ne configure pas son provider »C'est la règle la plus citée, et la plus mal justifiée. Le motif n'est pas la souplesse, c'est le cycle de vie : « A provider configuration must always stay present in the overall Terraform configuration for longer than all of the resources it manages. » Un module qui embarque à la fois ses ressources et la configuration qui les gère disparaît d'un seul bloc, et il devient impossible de détruire proprement ce qu'il avait créé.
Terraform sanctionne d'ailleurs l'appel, dès que ce bloc provider configure
réellement quelque chose :
Error: Module is incompatible with count, for_each, and depends_on
The module at module.cle is a legacy module which contains its own localprovider configurations, and so calls to it may not use the count, for_each,or depends_on arguments.
If you also control the module "./mod", consider updating this module toinstead expect provider configurations to be passed by its caller.Une nuance mesurée vaut d'être connue, car elle explique pourquoi le problème
passe parfois inaperçu : un bloc provider vide ne produit qu'un
avertissement, Warning: Redundant empty provider block. C'est le bloc qui
porte des arguments qui rend le module incompatible avec count, for_each
et depends_on.
Passer une configuration à un module
Section intitulée « Passer une configuration à un module »L'héritage implicite existe, mais il ne couvre que la configuration par
défaut : « Aliased providers are never inherited automatically and must be
passed explicitly using the providers argument. » Or c'est précisément
l'aliasing que l'on invoque pour justifier l'interdiction du bloc provider,
multi-région ou multi-compte. Il faut donc deux déclarations.
Côté module, l'attente se déclare dans required_providers :
terraform { required_providers { local = { source = "hashicorp/local" version = ">= 2.5" configuration_aliases = [local.plaques] } }}
resource "local_file" "plaque" { provider = local.plaques
filename = "${path.root}/${var.repertoire}/${var.nom}.txt" content = "plaque ${var.nom}\n"}Côté appelant, la configuration se passe :
provider "local" { alias = "atelier"}
module "plaque" { source = "../bibliotheque/plaque"
providers = { local.plaques = local.atelier }
nom = "nord" repertoire = "sorties"}Oubliez l'argument providers, et l'init s'arrête en nommant ce qui
manque :
Error: Missing required provider configuration
The child module requires an additional configuration for providerhashicorp/local, with the local name "local.secondaire".C'est le mode d'emploi qui manque à la plupart des énoncés de la règle :
interdire le bloc provider sans donner configuration_aliases et
providers laisse le lecteur sans solution pour son cas multi-région.
L'inversion de dépendance
Section intitulée « L'inversion de dépendance »C'est le cœur de la page officielle sur la composition, et le point qui distingue un module réutilisable d'un module captif : un module reçoit ce dont il dépend, il ne le fabrique pas.
# avant : le module decidelocals { repertoire = "plaques"}
# apres : l'appelant decidevariable "repertoire" { type = string description = "Repertoire ou ecrire la plaque."}Le bénéfice est concret, et il va plus loin qu'un paramètre de plus.
L'appelant peut passer une ressource qu'il crée, ou une data source qui
lit un existant, sans que le module change d'une ligne. Le module
consul_cluster de l'exemple officiel accepte ainsi des identifiants de VPC et de
sous-réseaux, il ne les crée pas.
| Le module... | Conséquence pour l'appelant |
|---|---|
| crée sa dépendance | il subit ce choix, et ne peut pas réutiliser l'existant |
interroge sa dépendance par une data source | il subit la requête, et les droits qu'elle exige |
| reçoit sa dépendance en entrée | il décide, ressource neuve ou existante |
La création conditionnelle relève du même principe : plutôt qu'un count
caché dans le module, déclarez une variable d'objet et laissez l'appelant
décider s'il crée l'objet ou en référence un existant.
Garder l'arbre plat
Section intitulée « Garder l'arbre plat »La recommandation officielle est explicite : « we strongly recommend keeping the module tree flat, with only one level of child modules ». Les relations entre modules passent par des expressions entre appels, comme entre ressources, pas par une hiérarchie profonde.
module "reseau" { source = "./modules/reseau"}
module "machine" { source = "./modules/machine"
reseau_id = module.reseau.id}Un arbre profond coûte deux fois : la lecture, puisqu'il faut descendre pour comprendre ce qui est créé, et la modification, puisqu'une entrée nouvelle doit être relayée à chaque étage.
Un module documente son contrat
Section intitulée « Un module documente son contrat »La Standard Module Structure range le README.md dans le minimum, aux côtés
de main.tf, variables.tf et outputs.tf. Ce n'est pas une politesse :
c'est le fichier que le registre et les générateurs de documentation
exploitent, et la doc précise qu'il n'a pas besoin de lister les entrées et
sorties, l'outillage s'en chargeant.
Deux règles complètent le contrat. Un module réutilisable ne contraint que son
plancher de version, la borne haute appartenant au module racine. Et ce
qu'un module garantit se déclare : les validation couvrent les entrées,
les precondition et postcondition couvrent les invariants de ce qu'il
produit.
Prouver ces pratiques sans relire le code
Section intitulée « Prouver ces pratiques sans relire le code »Une liste de bonnes pratiques se coche à la main, ce qui la rend invérifiable en revue. Le JSON du plan, lui, expose la configuration telle que Terraform l'a comprise.
terraform plan -out=plan.tfplanterraform show -json plan.tfplan | jq '.configuration.provider_config'{ "local": { "name": "local", "full_name": "registry.terraform.io/hashicorp/local" }, "module.plaque:local": { "name": "local", "full_name": "registry.terraform.io/hashicorp/local", "module_address": "module.plaque" }}Le champ module_address n'apparaît que pour une configuration déclarée
dans un module : c'est le détecteur exact de la violation, et il tient en
une ligne de jq. À l'inverse, une configuration aliasée passée par
l'appelant apparaît au niveau racine, avec son alias.
| Ce que vous voulez vérifier | Où le lire |
|---|---|
| aucun provider configuré dans un module | provider_config[].module_address absent |
| quelle configuration sert quel module | module_calls[].module.resources[].provider_config_key |
| l'interface réelle du module | module_calls[].module.variables |
| ce que l'appelant passe vraiment | module_calls[].expressions |
| ce que le module gère ou lit | mode valant managed ou data |
Les autres pratiques, en bref
Section intitulée « Les autres pratiques, en bref »-
Une seule responsabilité par module, et un nom qui la dit.
-
Des entrées typées et validées, avec un
defaultpour tout ce qui a une valeur raisonnable, etnullable = falselà où le défaut doit toujours s'appliquer. -
Des sorties utiles, avec
descriptionettype, car elles sont la seule chose que l'appelant voit. -
Un module « data only » quand le besoin est de lire une infrastructure : il encapsule la requête, et permet de basculer vers un remote state sans toucher aux consommateurs.
-
Des exemples sous
examples/, dont lesourcepointe l'adresse externe du module, celle qu'un appelant emploierait. -
Une version publiée par tag, et un
CHANGELOGqui dit ce qui change.
Dépannage
Section intitulée « Dépannage »| Symptôme | Cause probable | Correction |
|---|---|---|
Module is incompatible with count, for_each, and depends_on | le module configure un provider | retirer le bloc, déclarer configuration_aliases |
Warning: Redundant empty provider block | bloc provider vide dans un module | le supprimer, il ne sert à rien |
Missing required provider configuration | l'appel ne passe pas l'argument providers | ajouter providers = { ... } |
| le module ne se laisse pas détruire proprement | configuration de provider partie avec le module | remettre la configuration à la racine |
| une entrée nouvelle doit être relayée sur trois étages | arbre de modules profond | aplatir, relier par des expressions |
| l'appelant ne peut pas réutiliser un existant | le module crée sa dépendance | la transformer en entrée |
Mettre en pratique
Section intitulée « Mettre en pratique »Le lab bonnes pratiques des modules remet un module « legacy » : il configure
son provider et décide seul de son répertoire de sortie. Il faut le rendre
composable, puis faire produire au projet deux plaques depuis un seul
appel. Les tests ne lisent aucun .tf : ils cherchent module_address dans le
JSON du plan, vérifient l'alias passé par l'appelant, comparent l'interface du
module à ce que l'appel lui transmet, et lisent les deux instances dans l'état. Il
se joue hors ligne.
À retenir
Section intitulée « À retenir »- Un module ne configure pas son provider : la configuration doit survivre aux ressources qu'elle gère.
- Un bloc
providervide ne fait qu'avertir ; un bloc configuré interditcount,for_eachetdepends_on. - Une configuration aliasée ne s'hérite jamais :
configuration_aliasescôté module,providerscôté appel. - Un module reçoit ses dépendances, il ne les fabrique pas : l'appelant garde le choix entre créer et réutiliser.
- L'arbre des modules reste plat, les relations passant par des expressions.
- Le
README.mdfait partie du minimum officiel, et l'outillage s'appuie dessus. - Un module réutilisable ne contraint que son plancher de version.
- Chaque pratique se prouve dans le JSON du plan :
module_address,provider_config_key,variables,expressions,mode.
FAQ : questions fréquentes
Section intitulée « FAQ : questions fréquentes »Les questions ci-dessous portent sur ce qui casse en pratique : le module qu'on ne peut pas appeler deux fois, la configuration de provider qui ne s'hérite pas, et la revue qu'on voudrait automatiser.
Un problème de cycle de vie, pas de confort
« A provider configuration must always stay present in the overall Terraform configuration for longer than all of the resources it manages. »Mesuré sur Terraform 1.15.4, avec un module qui configure réellement son provider :Error: Module is incompatible with count, for_each, and depends_on
The module at module.cle is a legacy module which contains its own local
provider configurations, and so calls to it may not use the count, for_each,
or depends_on arguments.
Nuance utile : un bloc provider vide ne produit qu'un Warning: Redundant empty provider block. C'est le bloc qui porte des arguments qui bride l'appel.Deux déclarations, une de chaque côté
Côté module :terraform {
required_providers {
local = {
source = "hashicorp/local"
version = ">= 2.5"
configuration_aliases = [local.plaques]
}
}
}
resource "local_file" "plaque" {
provider = local.plaques
...
}
Côté appelant :provider "local" {
alias = "atelier"
}
module "plaque" {
source = "../bibliotheque/plaque"
providers = {
local.plaques = local.atelier
}
}
Sans l'argument providers, l'init s'arrête : Error: Missing required provider configuration, en nommant l'alias attendu.L'héritage ne couvre que le défaut
« Aliased providers are never inherited automatically and must be passed explicitly using theproviders argument. »La conséquence est souvent ratée : on interdit le bloc provider dans un module en invoquant le multi-région, alors que ce cas exige justement une configuration aliasée, donc un passage explicite.Mesuré sur 1.15.4 : sans l'argument providers, l'init échoue.Error: Missing required provider configuration
The child module requires an additional configuration for provider
hashicorp/local, with the local name "local.secondaire".
Recevoir plutôt que fabriquer
# avant : le module decide
locals {
repertoire = "plaques"
}
# apres : l'appelant decide
variable "repertoire" {
type = string
description = "Repertoire ou ecrire la plaque."
}
| Le module... | Conséquence pour l'appelant |
|---|---|
| crée sa dépendance | il subit ce choix |
interroge par une data source |
il subit la requête et ses droits |
| reçoit en entrée | il décide, neuf ou existant |
consul_cluster de la documentation : il accepte des identifiants de VPC et de sous-réseaux, il ne les crée pas.Une ligne de jq remplace une checklist
terraform plan -out=plan.tfplan
terraform show -json plan.tfplan | jq '.configuration.provider_config'
{
"module.plaque:local": {
"name": "local",
"module_address": "module.plaque"
}
}
| Ce que vous vérifiez | Où le lire |
|---|---|
| aucun provider configuré dans un module | provider_config[].module_address absent |
| quelle configuration sert quel module | module_calls[].module.resources[].provider_config_key |
| l'interface réelle du module | module_calls[].module.variables |
| ce que l'appelant passe | module_calls[].expressions |
| ce qu'il gère ou lit | mode valant managed ou data |
Un seul niveau, des expressions entre appels
« We strongly recommend keeping the module tree flat, with only one level of child modules. »module "reseau" {
source = "./modules/reseau"
}
module "machine" {
source = "./modules/machine"
reseau_id = module.reseau.id
}
Le coût d'un arbre profond est double : il faut descendre pour comprendre ce qui est réellement créé, et toute entrée nouvelle doit être relayée à chaque étage intermédiaire, qui n'en fait rien d'autre que la transmettre.Le fichier que l'outillage lit
La documentation officielle placeREADME.md dans l'arbre minimal d'un module, et précise : « The root module and any nested modules should have README files. »Deux conséquences pratiques :- un module imbriqué doté d'un README est réputé utilisable de l'extérieur, sans README il est considéré comme interne ;
- le README n'a pas besoin de documenter les entrées et sorties, « tooling will automatically generate this ».
Le module annonce un plancher, la racine choisit le plafond
« Reusable modules should constrain only their minimum allowed versions », tandis que « Root modules should use a~> constraint to set both a lower and upper bound ».Mesuré sur Terraform 1.15.4, avec un module en ~> 2.4.0 et une racine en >= 2.9 :Error: Failed to query available provider packages
Could not retrieve the list of available versions for provider
hashicorp/local: no available releases match the given constraints ~> 2.4.0,
>= 2.9.0
Les contraintes s'intersectent : le consommateur est bloqué, et seul un nouveau tag du module le débloque.Pour aller plus loin
Section intitulée « Pour aller plus loin »- Quiz Modules Terraform : un contrôle des acquis sur la conception, le test et la publication des modules.
- Composition de modules : la référence officielle : inversion de dépendance, arbre plat et modules « data only ».
- Providers dans les modules : la référence officielle :
configuration_aliaseset l'argumentproviders.