Concevoir l'interface d'un module
Pourquoi
À la leçon précédente, le module reseau a été extrait sans rien détruire. Mais son interface est celle qui est venue toute seule : cinq variables de types simples, trois sorties. Pour un seul appelant, ce n'est pas un problème. Dès que d'autres équipes l'utilisent, chaque faiblesse de l'interface se paie.
- Une variable
sous_reseauqui accepte"banane"ne le dit qu'au moment où le fournisseur refuse la requête, après de longues secondes de planification et un message de l'API qui ne cite pas le nom de la variable. - Une variable obligatoire de plus (par exemple
passerelle) casse tous les appelants existants. - Un module qui expose vingt variables vous demande de comprendre vingt choses avant de créer un réseau.
- Un bloc
providercaché dans le module empêche de l'appeler avecfor_eachet fait échouer sa suppression.
L'interface d'un module est un contrat, et c'est la partie du module la plus difficile à changer, parce que ses appelants sont ailleurs : dans d'autres dépôts, d'autres équipes, d'autres environnements. Le code intérieur se réécrit à volonté. Cette leçon étudie comment concevoir ce contrat : étroit, typé, validé, documenté et stable.
Les concepts
Une interface étroite
Un module expose ce que l'appelant doit décider, pas tout ce que le fournisseur permet. Brikman le formule ainsi : un module doit faire une seule chose bien, et ses entrées doivent être celles qui varient réellement. Le réseau de Signalements en offre deux cas.
La plage 172.16.20.0/22 varie d'un client à l'autre : c'est une entrée. Le nom du VPC dépend de l'environnement : entrée aussi, via environnement. Le type de passerelle (VPC-GW-S) change rarement : valeur par défaut. Le fait que le masquerade soit activé sur la passerelle ne varie pas du tout dans notre architecture : ce n'est pas une variable, c'est une décision du module, écrite en dur. Chaque variable que l'on ne crée pas est une décision que le module garde, et une option de moins à tester.
Deux repères : commencer étroit (ajouter une variable optionnelle est compatible avec tous les appelants, en retirer une ne l'est jamais) et exposer des concepts du domaine (passerelle = { plages_autorisees = [...] }) plutôt que des arguments du fournisseur.
Les types : du simple à l'objet
Une variable sans type accepte n'importe quoi, ce qui reporte les erreurs à l'intérieur du module. Une contrainte de type, elle, est vérifiée à l'entrée. L'éventail des types :
| Type | Exemple |
|---|---|
| Primitifs | string, number, bool |
| Collections homogènes | list(string), set(string), map(string) |
| Structure fixe | object({ nom = string, taille = number }) |
| Liste de position fixe | tuple([string, number]) (rare) |
| Tout type | any (à éviter) |
Pour un module, l'objet est le type le plus utile, parce qu'il regroupe les paramètres d'un même concept en une seule entrée. L'objet est strict : un attribut inconnu est une erreur, un attribut manquant aussi, sauf s'il est déclaré optionnel.
optional : des attributs qui ont une valeur par défaut
Dans un type object, optional(type) déclare un attribut facultatif, et optional(type, défaut) lui donne une valeur par défaut. La fonctionnalité est stable depuis Terraform 1.3.
variable "disque" {
description = "Disque système."
type = object({
taille_go = optional(number, 20)
type = optional(string, "b_ssd")
})
default = {}
}Trois cas se présentent, et il faut les distinguer :
- attribut absent : l'appelant écrit
disque = { taille_go = 50 }, le module reçoit{ taille_go = 50, type = "b_ssd" }: la valeur par défaut est complétée ; - objet absent : l'appelant ne dit rien,
default = {}s'applique et chaque attribut reprend son défaut :{ taille_go = 20, type = "b_ssd" }; optional(type)sans défaut : un attribut absent vautnull, et c'est au module de gérer ce cas.
L'astuce default = {} (ou default = null pour un objet qui peut être entièrement absent) est ce qui permet de rendre un objet complet facultatif. Sans elle, l'appelant devrait écrire disque = {} pour obtenir les valeurs par défaut.
null, vide, absent et nullable
Il y a trois façons de « ne rien dire » et elles ne sont pas équivalentes :
- une variable non définie : Terraform utilise le
default; - une variable explicitement
null: par défaut, la valeur passée estnullet ledefaultest ignoré ; - une variable vide (
[],{},"") : une valeur valide qui n'est pasnull.
Le troisième cas est une source classique d'erreurs de module : un appelant qui transmet une variable elle-même optionnelle dans son propre code (etiquettes = var.etiquettes) envoie null si elle n'a pas été définie, et le module reçoit null au lieu de son défaut. L'argument nullable = false sur la variable corrige cela : null n'est plus accepté, le default est utilisé à la place. Il est disponible depuis Terraform 1.1, et il faut en faire l'habitude pour toutes les variables qui ont un défaut et que le module utilise sans test de null.
À l'inverse, default = null est un choix de conception : il signifie « cette fonctionnalité est désactivée par défaut ». C'est le bon moyen de modéliser une option : passerelle = null veut dire « pas de passerelle », passerelle = { plages_autorisees = [...] } veut dire « avec passerelle ». Le module teste ensuite var.passerelle == null.
Les validations
Le type vérifie la forme des données ; le bloc validation vérifie leur sens. Il se place dans la variable :
variable "nom" {
type = string
validation {
condition = can(regex("^[a-z][a-z0-9-]{2,29}$", var.nom))
error_message = "Le nom doit faire 3 à 30 caractères : minuscules, chiffres et tirets, première lettre alphabétique."
}
}conditionest une expression booléenne ; elle doit référencer la variable elle-même (var.nomici), faute de quoi Terraform refuse le bloc.error_messageest une chaîne qui explique la règle et la façon de la satisfaire. On peut y interpoler des valeurs, comme${var.sous_reseau}.can()transforme une expression qui peut échouer (regexsans correspondance,cidrhostavec une plage invalide) en booléen. C'est l'idiome de base des validations.- On peut déclarer plusieurs blocs
validationpar variable : chacun produit son propre message.
Depuis Terraform 1.9, la condition peut aussi lire d'autres variables du module (à condition de lire aussi la variable elle-même). C'est ce qui permet de vérifier la cohérence entre deux entrées, par exemple que les adresses réservées appartiennent bien au sous-réseau :
validation {
condition = alltrue([
for a in values(var.adresses_instances) :
contains([for n in range(pow(2, 32 - tonumber(split("/", var.sous_reseau)[1]))) : cidrhost(var.sous_reseau, n)], a)
])
error_message = "Toutes les adresses d'instances doivent appartenir au sous-réseau ${var.sous_reseau}."
}L'expression énumère toutes les adresses du sous-réseau (1024 pour un /22) pour y chercher chaque adresse. Elle est correcte, un peu coûteuse en lecture, et acceptable pour une plage de cette taille.
Les sorties utiles
Une sortie répond à une question que l'appelant se pose : « quel est l'identifiant du réseau ? » plutôt que « quelles sont les valeurs de toutes les ressources ? ». Chaque sortie porte une description (qui alimentera la documentation) et peut être :
sensitive = truequand elle contient une valeur sensible : Terraform l'exige si la valeur en vient d'une variable ou d'un attribut sensibles ;precondition: une vérification faite avant d'exposer la valeur, qui sert à garantir une promesse faite par le module ;deprecated(Terraform 1.15) : un message affiché à l'appelant qui utilise la sortie, pour annoncer sa disparition (voir plus loin).
Préférez des sorties typées simplement (identifiants, listes d'identifiants, tables clé vers identifiant) à des sorties qui renvoient un objet de ressource complet. Une sortie value = scaleway_vpc_private_network.app expose tous les attributs et empêche le module de réorganiser ses ressources sans casser l'appelant.
Pas de bloc provider dans un module réutilisable
Un module réutilisable déclare les fournisseurs dont il a besoin, avec leurs contraintes de version, dans required_providers ; il ne les configure pas. La configuration (région, zone, identifiants, alias) appartient au module racine, qui sait dans quel contexte le module est appelé. Les raisons, détaillées dans la documentation HashiCorp :
- un module qui configure son propre fournisseur ne peut pas être appelé avec
count,for_eachnidepends_on; - sa suppression de la configuration devient impossible tant que ses ressources existent encore, parce que Terraform a besoin de la configuration du fournisseur pour détruire les objets ;
- il fige un choix (la région, par exemple) que l'appelant devrait pouvoir faire.
Quand un module a besoin de deux configurations du même fournisseur (une zone secondaire, par exemple), il le déclare avec configuration_aliases et l'appelant les lui passe avec le méta-argument providers. Un module simple n'en a pas besoin : il reçoit par défaut la configuration du fournisseur du même nom dans l'appelant.
count et for_each sur un module
Un module s'appelle en boucle comme une ressource (for_each = toset(["fr-par-1", "fr-par-2"])), avec les mêmes règles : clés connues au plan, pas de valeur sensible, instances identifiées par leur clé, sorties indexées (module.instance["fr-par-1"]). Le prévoir dès la conception (pas de bloc provider, pas de noms en dur) rend le module appelable en boucle.
Dépréciation : changer l'interface sans surprise
Terraform 1.15 a ajouté un argument deprecated aux blocs variable et output. Quand un appelant passe une valeur à une variable dépréciée, ou lit une sortie dépréciée, Terraform affiche un avertissement avec le message du module. C'est le bon outil pour annoncer qu'une entrée sera remplacée avant de la retirer, en laissant le temps aux appelants (voir la leçon 3 sur les versions).
La structure d'un répertoire de module
La documentation de HashiCorp décrit une disposition standard, que le registre public suppose :
reseau/
├── README.md ce que fait le module, un exemple d'appel
├── main.tf les ressources (ou main.tf plus un fichier par domaine)
├── variables.tf toutes les variables d'entrée
├── outputs.tf toutes les sorties
├── versions.tf required_version et required_providers
├── examples/
│ ├── simple/main.tf l'appel minimal
│ └── avec-passerelle/main.tf
└── tests/
└── reseau.tftest.hcl (leçon 5)Les noms ne sont pas imposés par Terraform, qui lit tous les .tf du répertoire, mais leur régularité permet à tout lecteur de savoir où chercher. Le README est obligatoire pour qu'un module soit publié dans le registre public. Le répertoire examples/ donne aux appelants des configurations complètes à copier, et aux mainteneurs un jeu de configurations à valider en CI.
La documentation des entrées et sorties se génère : terraform-docs lit les description, les types et les défauts, et injecte les tableaux dans le README (terraform-docs markdown table --output-file README.md --output-mode inject .). Relancé en CI, il empêche la dérive.
En pratique
Une interface qui échoue tôt
Un module de démonstration, instance, qui ne crée qu'un terraform_data mais dont l'interface est celle d'un vrai module. Ses variables :
variable "nom" {
description = "Préfixe des noms de ressources."
type = string
validation {
condition = can(regex("^[a-z][a-z0-9-]{2,29}$", var.nom))
error_message = "Le nom doit faire 3 à 30 caractères : minuscules, chiffres et tirets, première lettre alphabétique."
}
}
variable "disque" {
description = "Disque système."
type = object({
taille_go = optional(number, 20)
type = optional(string, "b_ssd")
})
default = {}
validation {
condition = var.disque.taille_go >= 10 && var.disque.taille_go <= 200
error_message = "La taille du disque doit être comprise entre 10 et 200 Go."
}
}
variable "sauvegarde" {
description = "Configuration de sauvegarde, ou null pour la désactiver."
type = object({
retention_jours = optional(number, 7)
heure_utc = optional(number, 2)
})
default = null
}
variable "etiquettes" {
description = "Étiquettes ; null revient à la valeur par défaut."
type = list(string)
default = ["gere-par=terraform"]
nullable = false
}Appelé avec deux valeurs invalides, le module les refuse toutes les deux avant de toucher à quoi que ce soit :
$ terraform plan -no-color
Error: Invalid value for variable
on main.tf line 11, in module "b":
11: nom = "Sig_App"
├────────────────
│ var.nom is "Sig_App"
Le nom doit faire 3 à 30 caractères : minuscules, chiffres et tirets,
première lettre alphabétique.
This was checked by the validation rule at
modules/instance/variables.tf:5,3-13.
Error: Invalid value for variable
on main.tf line 12, in module "b":
12: disque = { taille_go = 5 }
├────────────────
│ var.disque.taille_go is 5
La taille du disque doit être comprise entre 10 et 200 Go.
This was checked by the validation rule at
modules/instance/variables.tf:19,3-13.
Le message pointe l'appelant (la ligne nom = "Sig_App" du bloc module "b"), cite la valeur fautive, puis le message du module et l'emplacement de la règle. C'est ce que vise une bonne validation : l'utilisateur sait quoi corriger sans lire le module.
Les valeurs par défaut et null
Avec un appel valide, qui passe une taille de disque, une sauvegarde vide et etiquettes = null :
module "a" {
source = "./modules/instance"
nom = "sig-app-1"
disque = { taille_go = 50 }
sauvegarde = {}
etiquettes = null
}la sortie du module configuration montre ce que le module a réellement reçu :
a = {
"disque" = {
"taille_go" = 50
"type" = "b_ssd"
}
"etiquettes" = tolist([
"gere-par=terraform",
])
"nom" = "sig-app-1"
"sauvegarde" = {
"heure_utc" = 2
"retention_jours" = 7
}
}
Constats issus de cette exécution : type a reçu son défaut b_ssd ; sauvegarde = {} a activé la sauvegarde avec ses valeurs par défaut (l'omission l'aurait laissée à null) ; etiquettes = null a été remplacé par le défaut grâce à nullable = false.
Un module appelé avec for_each
module "zones" {
source = "./modules/instance"
for_each = toset(["fr-par-1", "fr-par-2"])
nom = "sig-${each.key}"
}
output "zones" {
value = { for k, m in module.zones : k => m.configuration.nom }
}zones = {
"fr-par-1" = "sig-fr-par-1"
"fr-par-2" = "sig-fr-par-2"
}
$ terraform state list
module.a.terraform_data.instance
module.zones["fr-par-1"].terraform_data.instance
module.zones["fr-par-2"].terraform_data.instance
Les adresses d'état contiennent la clé de l'instance du module. Retirer fr-par-1 de l'ensemble ne détruit que les ressources de module.zones["fr-par-1"].
Le module réseau de Signalements, version finale
Le module reseau de la leçon 1 reçoit maintenant la passerelle publique et son bastion, sous forme d'une option : un objet avec des valeurs par défaut, ou null pour s'en passer.
# modules/reseau/variables.tf (ajout)
variable "passerelle" {
description = "Passerelle publique (sortie Internet du réseau privé) et bastion SSH ; null pour s'en passer."
type = object({
type = optional(string, "VPC-GW-S")
bastion_port = optional(number, 61000)
plages_autorisees = list(string)
})
default = null
}plages_autorisees est le seul attribut obligatoire : on ne peut pas activer un bastion sans dire qui peut s'y connecter, et on ne veut pas de valeur par défaut qui l'ouvrirait à tous. Les ressources, toutes conditionnelles :
# modules/reseau/passerelle.tf
resource "scaleway_vpc_public_gateway_ip" "passerelle" {
count = var.passerelle == null ? 0 : 1
tags = var.etiquettes
}
resource "scaleway_vpc_public_gateway" "passerelle" {
count = var.passerelle == null ? 0 : 1
name = "gw-signalements-${var.environnement}"
type = var.passerelle.type
ip_id = scaleway_vpc_public_gateway_ip.passerelle[0].id
bastion_enabled = true
bastion_port = var.passerelle.bastion_port
allowed_ip_ranges = var.passerelle.plages_autorisees
tags = var.etiquettes
}
resource "scaleway_vpc_gateway_network" "app" {
count = var.passerelle == null ? 0 : 1
gateway_id = scaleway_vpc_public_gateway.passerelle[0].id
private_network_id = scaleway_vpc_private_network.app.id
enable_masquerade = true
ipam_config {
push_default_route = true
}
}L'appel dans le module racine :
module "reseau" {
source = "./modules/reseau"
environnement = var.environnement
adresses_instances = { for nom, i in local.instances : nom => i.adresse }
etiquettes = local.etiquettes
passerelle = {
plages_autorisees = var.plages_bastion
}
}Le nom de la passerelle a été rendu unique par environnement (gw-signalements-${var.environnement}), ce qui répond au piège de la leçon précédente. Les ressources changent d'adresse (avec un index [0], que moved sait cibler : to = module.reseau.scaleway_vpc_public_gateway.passerelle[0]). Ce code a été vérifié avec terraform init et terraform validate (fournisseur 2.84.0), jamais planifié sur une infrastructure réelle.
La passerelle utilise count plutôt que for_each : c'est un cas « zéro ou un », le seul où count est le bon choix (la leçon 9 du premier cours en explique les limites pour les listes).
Passer des fournisseurs explicitement
Si le module avait besoin d'une seconde configuration de fournisseur, par exemple pour créer des ressources dans la zone fr-par-2, il le déclarerait ainsi :
# modules/reseau/versions.tf
terraform {
required_providers {
scaleway = {
source = "scaleway/scaleway"
version = ">= 2.84, < 3.0"
configuration_aliases = [scaleway.secondaire]
}
}
}et l'appelant lui passerait ses configurations :
provider "scaleway" {
region = "fr-par"
zone = "fr-par-1"
}
provider "scaleway" {
alias = "fr_par_2"
region = "fr-par"
zone = "fr-par-2"
}
module "reseau" {
source = "./modules/reseau"
providers = {
scaleway = scaleway
scaleway.secondaire = scaleway.fr_par_2
}
# ...
}Cette syntaxe a été vérifiée par terraform validate. Un module qui n'utilise qu'une configuration n'écrit pas de providers.
Déprécier une entrée
Pour remplacer type_instance par instance.type, le module garde l'ancienne variable quelque temps et la déclare dépréciée, puis fait de même pour une sortie renommée :
variable "type_instance" {
type = string
default = "PRO2-XXS"
deprecated = "Utilisez la variable instance.type, qui remplace type_instance."
}
output "ancien_nom" {
value = var.instance.type
deprecated = "Utilisez la sortie type_effectif."
}Un appelant qui passe encore type_instance = "PRO2-S" voit, à chaque plan (sortie réelle avec Terraform 1.16.1) :
Warning: Deprecated variable got a value
on main.tf line 3, in module "m":
3: type_instance = "PRO2-S"
Utilisez la variable instance.type, qui remplace type_instance.
Warning: Deprecated value used
on main.tf line 5, in output "x":
5: output "x" { value = module.m.ancien_nom }
The deprecation originates from module.m.ancien_nom
Utilisez la sortie type_effectif.
L'avertissement n'interrompt rien : il laisse le temps à chaque appelant de migrer. L'argument deprecated n'existe pas dans les versions de Terraform antérieures à 1.15 ; un module qui l'utilise doit déclarer required_version = ">= 1.15". Si vos appelants sont sur des versions plus anciennes, notez la dépréciation dans le README et dans les notes de version à la place.
Sous le capot
Les valeurs par défaut des objets sont appliquées par la conversion de type. Quand l'appelant passe une valeur, Terraform la convertit vers le type déclaré : c'est à cette étape que les attributs absents reçoivent leur valeur optional(type, défaut), que les nombres écrits en chaîne deviennent des nombres, et qu'un attribut inconnu provoque une erreur. La validation s'exécute après cette conversion, sur la valeur complétée : dans la démonstration, la règle var.disque.taille_go >= 10 s'évalue sur 20 (le défaut) quand l'appelant n'a rien dit, sans que le module teste l'absence.
null est une valeur, pas une absence. Quand l'appelant écrit etiquettes = null, Terraform ne se demande pas « a-t-il donné une valeur ? » : il en voit une, null. C'est nullable = false qui lui dit de remplacer null par le default. Pour les attributs d'objet, le comportement est plus généreux : un attribut optional(number, 20) reçoit son défaut aussi bien quand il est absent que quand l'appelant l'a explicitement mis à null (vérifié avec Terraform 1.16.1 : { taille = null } devient { taille = 20 }). Seule la variable elle-même, au niveau racine, reste null si on lui passe null sans nullable = false.
Les validations portent sur des valeurs connues au plan. Si la valeur dépend d'un attribut connu après l'apply, la règle ne peut pas s'évaluer et Terraform la reporte : réservez les validations aux paramètres choisis par les humains (noms, plages, tailles).
La suppression d'un module qui contient un provider échoue. Terraform a besoin de la configuration du fournisseur pour détruire les ressources d'un module. Si le bloc provider est dans le module et que l'on retire l'appel du module, la configuration disparaît avant la destruction. Expérience réelle avec un module qui contient provider "random" {} et une ressource random_pet, appliqué, puis retiré de la configuration :
$ terraform plan -no-color
Error: Provider configuration not present
To work with module.m.random_pet.p (orphan) its original provider
configuration at module.m.provider["registry.terraform.io/hashicorp/random"]
is required, but it has been removed. This occurs when a provider
configuration is removed while objects created by that provider still exist
in the state. Re-add the provider configuration to destroy
module.m.random_pet.p (orphan), after which you can remove the provider
configuration again.
Pour supprimer ce module, il faut d'abord remettre l'appel, le vider ou détruire ses ressources, puis le retirer. Avec le fournisseur dans la racine, aucun de ces détours n'est nécessaire. Même un bloc vide provider "random" {} dans le module est signalé comme obsolète par Terraform 1.16 : il demande de passer à une déclaration dans required_providers.
Pièges courants
any partout. Une variable de type any accepte tout, et le type effectif devient celui de la première valeur, ce qui produit des erreurs d'inférence obscures à l'intérieur du module. Un objet précis donne des messages lisibles et une documentation exacte.
Un attribut optional() sans défaut que le module utilise comme s'il avait une valeur. var.sauvegarde.retention_jours vaut null si l'appelant omet l'attribut : une expression comme var.sauvegarde.retention_jours * 24 échoue. Donnez un défaut, ou traitez null explicitement avec coalesce() ou try().
Confondre default = {} et default = null pour un objet. Avec default = {}, l'objet est toujours « présent » avec ses défauts, donc la fonctionnalité est toujours activée. Avec default = null, la fonctionnalité est désactivée par défaut : {} l'active alors avec les valeurs par défaut. Choisissez en pensant à ce que l'appelant qui ne dit rien doit obtenir.
Une validation qui ne référence pas sa variable. Terraform refuse le bloc avec The condition for variable "..." must refer to var... in order to test incoming values. Pour valider un rapport entre deux variables, placez la règle sur l'une et lisez l'autre.
Une variable obligatoire ajoutée sans prévenir. Elle casse tous les appelants au prochain init -upgrade ou à la prochaine montée de version. Ajoutez des variables optionnelles avec un défaut qui conserve l'ancien comportement.
Sécurité
Les défauts sont des décisions de sécurité. Un module dont le bastion s'ouvre par défaut à
0.0.0.0/0met chaque appelant distrait en infraction. Faites des valeurs dangereuses des entrées obligatoires, sans défaut (commeplages_autorisees), et des valeurs sûres les défauts.Validez ce qui touche à l'exposition. Un
validationqui refuse0.0.0.0/0dansplages_autoriseestransforme une revue en erreur de compilation :validation { condition = !contains(var.passerelle.plages_autorisees, "0.0.0.0/0") error_message = "Le bastion ne doit pas être ouvert à tout Internet : listez des plages précises." }Les validations d'objets optionnels doivent tolérer
null(var.passerelle == null || ...).sensitive = truesur les variables qui portent des secrets (mots de passe, jetons). Terraform masque la valeur dans le plan et les sorties de la CLI, mais l'état la contient en clair ; pour la tenir hors de l'état, voir la leçon Les secrets hors de l'état.Ne passez pas d'identifiants à un module. Un module qui reçoit une clé d'API en variable la place dans l'état et les journaux. Les identifiants du fournisseur sont configurés dans la racine, par l'environnement.
En production
- Mesurez la taille de l'interface. Plus de dix variables : le module fait trop de choses ou expose des détails. Gardez les noms et ajoutez des options ; ne cassez qu'avec une version majeure annoncée (leçon 3).
- Exemples exécutables. Chaque répertoire de
examples/est une configuration complète qu'on valide en CI (terraform init -backend=false && terraform validate) et que les tests de la leçon 5 peuvent réutiliser. Un exemple qui ne compile plus signale un changement d'interface involontaire. - OpenTofu.
optional(),nullableetvalidationfonctionnent à l'identique. L'argumentdeprecateddes variables et sorties est une nouveauté de Terraform 1.15 : vérifiez sa disponibilité dans la documentation de la version d'OpenTofu que vous utilisez avant de vous appuyer dessus. Les modules appelés avecfor_eachsont identiques ; OpenTofu 1.9 a ajoutéfor_eachaux blocsprovider(un alias à plusieurs instances), ce que Terraform ne propose pas.
Exercices
Exercice 1 : écrire une validation
Écrivez une validation de sous_reseau qui exige une plage CIDR valide et un masque compris entre /16 et /24. Quels messages d'erreur donneriez-vous ?
Solution
variable "sous_reseau" {
type = string
default = "172.16.20.0/22"
validation {
condition = can(cidrhost(var.sous_reseau, 0))
error_message = "sous_reseau doit être une plage CIDR valide, par exemple 172.16.20.0/22."
}
validation {
condition = (
can(cidrhost(var.sous_reseau, 0)) &&
tonumber(split("/", var.sous_reseau)[1]) >= 16 &&
tonumber(split("/", var.sous_reseau)[1]) <= 24
)
error_message = "Le masque de sous_reseau doit être compris entre /16 et /24."
}
}Deux blocs séparés pour deux messages distincts. Le second répète can(cidrhost(...)) en tête de && pour que la condition reste fausse sur une chaîne invalide. Testé avec Terraform 1.16.1 : -var 'sous_reseau=pas-un-cidr' affiche les deux messages, 172.16.0.0/8 seulement le second, et la valeur par défaut aucune erreur.
Exercice 2 : repérer les défauts d'interface
Un collègue propose ce module base :
variable "config" { type = any }
provider "scaleway" { region = "fr-par" }
output "base" { value = scaleway_rdb_instance.principale }Relevez les problèmes et proposez des corrections.
Solution
type = any: aucune validation, aucune documentation, erreurs tardives. Remplacer par un objet précis avecoptional().provider "scaleway"dans le module : l'empêche d'être appelé avecfor_each, rend sa suppression impossible tant que la base existe, et fige la région. Déclarer le fournisseur dansrequired_providerset le configurer dans la racine.- La sortie
baserenvoie la ressource entière : l'appelant dépend de tous ses attributs, y compris des sensibles (mot de passe), et le module ne peut plus réorganiser ses ressources. Publier des sorties précises : identifiant, point d'accès privé, et marquersensitive = truece qui l'exige. - Aucune
description: la documentation générée sera vide.
Récapitulatif
- L'interface d'un module est un contrat : étroite (ce que l'appelant doit décider), typée (objets précis), validée, documentée.
optional(type, défaut)(Terraform 1.3) donne des valeurs par défaut aux attributs d'objet ;default = nulldésactive une option,default = {}l'active avec ses défauts.nullable = false(1.1) remplace unnullexplicite par ledefault; sans lui,nullécrase le défaut.- Un bloc
validationdoit référencer sa variable, peut lire d'autres variables depuis Terraform 1.9, et doit dire comment corriger ; les validations s'exécutent après l'application des défauts. - Un module réutilisable déclare ses fournisseurs (
required_providers,configuration_aliases) et ne les configure pas ; l'appelant les passe avecproviders. countetfor_eachsur un module exigent l'absence de blocproviderdans le module ; ses ressources prennent la clé dans leur adresse.- Les sorties publient des identifiants, pas des ressources entières ;
deprecated(Terraform 1.15) annonce un changement d'interface sans casser. - La structure standard (
README,main.tf,variables.tf,outputs.tf,versions.tf,examples/) etterraform-docsrendent les modules lisibles et vérifiables.
Pour aller plus loin
- La documentation HashiCorp sur les contraintes de type et sur les fournisseurs dans les modules, y compris la section sur les modules « legacy » avec configuration de fournisseur.
- La leçon 3 : publier ce module avec un numéro de version et gérer les changements cassants.
- La leçon 5 : tester l'interface (validations, valeurs par défaut) avec
terraform test. terraform-docs: génération de la documentation à partir du code, et vérification en CI.
Sources
- HashiCorp, Terraform : variables d'entrée (contraintes de type, optional, validation, nullable)
- HashiCorp, Terraform : contraintes de type
- HashiCorp, Terraform : les fournisseurs dans les modules
- HashiCorp, Terraform : structure standard d'un module
- HashiCorp, CHANGELOG de Terraform (versions 1.1, 1.3, 1.15)
- terraform-docs, documentation
- Yevgeniy Brikman, Terraform: Up & Running (3e éd., O'Reilly, 2022), chapitre 4