Aller au contenu
Infrastructure as Code medium

Utiliser un module Terraform local partagé entre projets

30 min de lecture

logo terraform

Un module local est un module posé sur le disque, appelé par un chemin relatif. C'est la façon la plus directe de partager du code entre plusieurs projets, sans Registry ni dépôt Git : le dossier est déjà là, chaque projet le désigne par source = "../chemin/vers/module". Encore faut-il savoir ce que Terraform en fait, parce que la mécanique n'est pas celle d'un module distant.

Tout ce qui suit a été exécuté sur Terraform v1.15.4 avec les providers local et random : sorties, erreurs et contenu de modules.json compris. Deux idées répandues n'y survivent pas, celle du cache à invalider après avoir modifié un module local, et celle du chemin absolu qui serait un chemin local comme un autre.

  • La règle qui fait qu'un chemin est local, et rien d'autre
  • Le montage bibliothèque plus projets, avec des states séparés
  • Ce que init enregistre vraiment, dans quel fichier
  • Comment un module en appelle un autre, et depuis où le chemin se résout
  • Quelle modification exige un nouvel init, et laquelle n'en demande aucun
  • Pourquoi path.module est indispensable dans un module partagé
  • Ce qu'un chemin absolu change, jusqu'à casser un module imbriqué
  • Comment prouver, JSON du plan en main, ce que l'appelant a passé

La documentation officielle est normative sur ce point, et c'est la phrase à retenir de tout ce guide : « A local path must begin with either ./ or ../ ». Le préfixe n'est pas une convention de lisibilité, c'est le signal qui oriente Terraform vers le disque plutôt que vers un téléchargement.

Enlevez ce préfixe, et Terraform part chercher un paquet distant :

module "gabarit" {
source = "commun/gabarit"
}
Error: Invalid module source address
Terraform failed to determine your intended installation method for remote
module package "commun/gabarit".
If you intended this as a path relative to the current module, use
"./commun/gabarit" instead. The "./" prefix indicates that the address is a
relative filesystem path.

Le message porte lui-même la correction, et nomme le mécanisme qu'il a supposé. Ce tableau résume ce que Terraform fait des quatre formes que l'on écrit le plus souvent, chacune vérifiée dans ce guide :

Ce que vous écrivezComment Terraform le traite
./modules/gabaritchemin local, lu sur place dans le projet
../commun/gabaritchemin local, lu sur place hors du projet
commun/gabaritrefusé : Invalid module source address
/opt/commun/gabaritpaquet distant recopié dans le cache de modules

Le schéma classique sépare la bibliothèque des projets qui la consomment :

mon-infra/
├── commun/ ← bibliothèque de modules
│ └── gabarit/
│ ├── versions.tf
│ ├── variables.tf
│ ├── main.tf
│ └── outputs.tf
├── atelier-nord/ ← projet 1, son propre state
│ ├── versions.tf
│ ├── main.tf
│ └── outputs.tf
└── atelier-sud/ ← projet 2, son propre state
├── versions.tf
├── main.tf
└── outputs.tf

Le dossier commun/gabarit/ porte le code une seule fois. Les deux ateliers le désignent par le même chemin relatif ../commun/gabarit, avec des valeurs différentes, et chacun garde son propre state.

Le module produit une plaque, un fichier nommé d'après une étiquette, avec un numéro de série facultatif. Sa structure suit le socle standard :

  1. versions.tf, les contraintes de version, sans bloc provider :

    terraform {
    required_version = ">= 1.11.0"
    required_providers {
    local = { source = "hashicorp/local", version = ">= 2.5" }
    random = { source = "hashicorp/random", version = ">= 3.6" }
    }
    }
  2. variables.tf, l'interface d'entrée, une variable obligatoire et une facultative :

    variable "etiquette" {
    type = string
    description = "Étiquette portée par la plaque."
    validation {
    condition = length(var.etiquette) >= 3
    error_message = "L'étiquette doit contenir au moins 3 caractères."
    }
    }
    variable "numeroter" {
    type = bool
    description = "Ajoute un numéro de série au nom du fichier."
    default = true
    }
  3. main.tf, les ressources paramétrées par ces variables :

    resource "random_integer" "serie" {
    min = 100
    max = 999
    }
    resource "local_file" "plaque" {
    filename = "${path.root}/plaques/${var.etiquette}${var.numeroter ? "-${random_integer.serie.result}" : ""}.txt"
    content = "plaque ${var.etiquette}\n"
    }
  4. outputs.tf, ce que l'appelant pourra lire :

    output "plaque" {
    value = local_file.plaque.filename
    description = "Chemin de la plaque produite."
    }
    output "configuration" {
    value = {
    etiquette = var.etiquette
    numerotee = var.numeroter
    }
    description = "Résumé de ce que l'appelant a demandé."
    }

L'atelier nord ne fournit que la variable obligatoire :

atelier-nord/main.tf
module "gabarit" {
source = "../commun/gabarit"
etiquette = "nord"
}

L'atelier sud consomme le même dossier, avec une autre étiquette et le numérotage désactivé :

atelier-sud/main.tf
module "gabarit" {
source = "../commun/gabarit"
etiquette = "sud"
numeroter = false
}

Les deux projets exposent ensuite les sorties du module, ce qui rend la comparaison lisible :

atelier-nord/outputs.tf
output "plaque" {
value = module.gabarit.plaque
}
output "configuration" {
value = module.gabarit.configuration
}

L'init annonce le module et le chemin retenu, sur une ligne facile à manquer :

$ cd atelier-nord
$ terraform init
Initializing modules...
- gabarit in ../commun/gabarit

La preuve que rien n'a été copié tient dans le répertoire de travail. Après l'init, .terraform/modules/ ne contient qu'un fichier, et aucun dossier :

Fenêtre de terminal
ls -A .terraform/modules/
modules.json

Ce modules.json, écrit par Terraform et par lui seul, est le registre des modules du projet. Il tient sur une ligne, avec une entrée vide qui représente la configuration racine :

{"Modules":[{"Key":"","Source":"","Dir":"."},{"Key":"gabarit","Source":"../commun/gabarit","Dir":"../commun/gabarit"}]}

Deux champs comptent. Source est ce que vous avez écrit, Dir est le dossier que Terraform lira. Pour un chemin relatif, les deux sont identiques et pointent hors du cache : c'est la signature d'un module lu sur place. La documentation le dit dans les mêmes termes, « the files are already present on local disk ».

Une bibliothèque finit toujours par se factoriser elle-même : un module appelle un voisin par ../, comme le fait ici gabarit avec etiquette. L'init annonce alors les deux, avec une clé hiérarchique :

Initializing modules...
- gabarit in ../commun/gabarit
- gabarit.etiquette in ../commun/etiquette

Le registre montre où se joue la résolution, et c'est le point que l'on comprend mal une fois sur deux :

[
{ "Key": "gabarit", "Source": "../commun/gabarit", "Dir": "../commun/gabarit" },
{ "Key": "gabarit.etiquette", "Source": "../etiquette", "Dir": "../commun/etiquette" }
]

Source reste tel quel, relatif au module qui l'écrit, tandis que Dir est normalisé depuis la racine. Autrement dit, un chemin source se résout depuis le dossier du fichier qui le contient, jamais depuis celui où vous lancez la commande. La documentation borne cette liberté à un paquet : « it is safe for a module in a sub-directory of a package to use a local path to another module as long as it is in the same package ». Retenez cette phrase, elle explique l'échec du chemin absolu vu plus loin.

Chaque atelier applique sa configuration, et obtient une plaque différente du même module :

$ cd atelier-nord && terraform apply
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
Outputs:
configuration = {
"etiquette" = "nord"
"numerotee" = true
}
plaque = "./plaques/nord-346.txt"
$ cd ../atelier-sud && terraform apply
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
Outputs:
configuration = {
"etiquette" = "sud"
"numerotee" = false
}
plaque = "./plaques/sud.txt"

Notez que l'atelier nord obtient numerotee = true sans avoir rien passé : c'est la valeur par défaut déclarée dans le module qui s'applique. Le nom du fichier produit en porte la trace, avec le numéro tiré par random_integer.

Les adresses dans le state, elles, sont rigoureusement les mêmes de part et d'autre :

$ terraform -chdir=atelier-nord state list
module.gabarit.local_file.plaque
module.gabarit.random_integer.serie
$ terraform -chdir=atelier-sud state list
module.gabarit.local_file.plaque
module.gabarit.random_integer.serie

Aucune collision pour autant : une adresse n'a de portée que dans son state, et ces deux states vivent dans deux dossiers différents. Détruire l'atelier nord ne touche pas l'atelier sud, ce qui est exactement l'isolation recherchée quand on sépare des environnements.

Une revue de code lit le .tf, un contrôle lit ce que Terraform en a compris. Le plan converti en JSON expose l'appel de module, et tranche une question que la sortie humaine laisse ouverte : cet argument a-t-il été passé ou laissé à son défaut ?

Fenêtre de terminal
terraform plan -out=plan.tfplan
terraform show -json plan.tfplan > plan.json

La clé à ouvrir est configuration.root_module.module_calls.gabarit :

{
"source": "../commun/gabarit",
"expressions": {
"etiquette": { "constant_value": "nord" }
}
}

Trois enseignements dans ces quelques lignes. Le champ version est absent, puisqu'il n'a pas de sens sur une source locale. Les expressions ne listent que etiquette, ce qui prouve que numeroter n'a pas été passé. Et le sous-arbre module de cet appel donne, lui, les deux variables déclarées : c'est la comparaison des deux listes qui établit ce qui vient du défaut.

Voici l'idée fausse la plus répandue sur les modules locaux. Modifiez le contenu du module partagé, sans relancer init, puis replanifiez depuis un atelier :

content = "plaque ${var.etiquette} (revision 2)\n"
# module.gabarit.local_file.plaque must be replaced
~ content = <<-EOT # forces replacement
Plan: 1 to add, 0 to change, 1 to destroy.
Fenêtre de terminal
terraform plan -detailed-exitcode; echo $?
2

Le changement est vu immédiatement. Il n'y a aucun cache à invalider pour le contenu d'un module local, puisque Dir désigne le dossier réel : Terraform le relit à chaque commande. Revenez en arrière, et le plan rend 0 aussitôt, toujours sans init.

Ce que l'init relit, c'est le champ source et lui seul. Trois situations le rendent donc obligatoire, et chacune se reconnaît à son message, qu'il vaut la peine de savoir distinguer :

Error: Module not installed
This module is not yet installed. Run "terraform init" to install all modules
required by this configuration.

Celui-ci tombe quand aucun init n'a été joué, et à l'identique quand vous supprimez .terraform/modules/modules.json : ce fichier est l'installation.

Error: Module not installed
This module's local cache directory ../commun/gabarit could not be read. Run
"terraform init" to install all modules required by this configuration.

Celui-là est plus intéressant, il nomme le dossier devenu illisible. Vous l'obtenez en déplaçant ou en renommant le module partagé : le registre pointe encore vers l'ancien emplacement.

Ce que vous changezinit nécessaire ?
le contenu du module (ressources, variables, outputs)non, vu au plan suivant
le champ source de l'appeloui
l'emplacement du dossier du moduleoui
.terraform/ supprimé, dépôt fraîchement clonéoui

Un module partagé lit souvent un fichier qu'il embarque : un template, un cloud-init, un JSON de référence. À l'intérieur du module, path.module vaut exactement ce que porte Dir, ce qu'une sortie de démonstration suffit à montrer :

output "emplacement_du_module" {
value = path.module
}
$ terraform output -raw emplacement_du_module
../commun/gabarit

Un chemin nu, lui, n'est pas résolu depuis le dossier du module mais depuis la racine d'exécution, et l'erreur tombe au plan :

Error: Invalid function argument
on ../commun/gabarit/modele.tf line 2, in locals:
2: modele = file("gabarits/exemple.txt")
├────────────────
│ while calling file(path)
Invalid value for "path" parameter: no file exists at "gabarits/exemple.txt";
this function works only with files that are distributed as part of the
configuration source code, so if this file will be created by a resource in
this configuration you must instead obtain this result from an attribute of
that resource.

La forme correcte ancre le chemin sur le module, et reste juste quel que soit le projet qui l'appelle :

locals {
modele = file("${path.module}/gabarits/exemple.txt")
}

Ce n'est pas une préférence de style : c'est la seule écriture qui survit au partage du module entre plusieurs projets, dont chacun a une racine différente. Les autres valeurs path.* sont détaillées dans expressions Terraform.

Un chemin absolu désigne pourtant bien un dossier présent sur le disque, et lisible. Terraform ne le considère pas local pour autant, et la documentation le dit sans détour : « Terraform does not consider an absolute filesystem path to be a local path. Instead, Terraform will treat that in a similar way as a remote module and copy it into the local module cache. »

L'init change de vocabulaire, il télécharge :

Initializing modules...
Downloading file:///home/bob/mon-infra/commun/gabarit for gabarit...
- gabarit in .terraform/modules/gabarit

Et le registre enregistre autre chose que ce que vous avez écrit :

{"Modules":[{"Key":"","Source":"","Dir":"."},{"Key":"gabarit","Source":"file:///home/bob/mon-infra/commun/gabarit","Dir":".terraform/modules/gabarit"}]}

Source est devenu une URL en file://, et Dir pointe désormais dans .terraform/. Une nuance mesurée sur 1.15.4 sous Linux : ce Dir est un lien symbolique vers la source, pas la copie profonde que le mot « copy » laisse imaginer.

$ ls -la .terraform/modules/
lrwxrwxrwx 1 bob bob 34 juil. 30 07:32 gabarit -> /home/bob/mon-infra/commun/gabarit
-rw-rw-r-- 1 bob bob 238 juil. 30 07:32 modules.json

Deux conséquences, dont la seconde coûte cher. La configuration devient non portable, ce que la documentation déconseille explicitement : « We don't recommend using absolute filesystem paths [...] because it will tend to couple your configuration to the filesystem layout of a particular computer ». Et un module devenu paquet ne peut plus atteindre ce qui est au-dessus de lui : s'il appelle un voisin par ../autre, comme le gabarit de la section précédente, l'init s'arrête net.

Error: Local module path escapes module package

Dernier écueil, celui d'un réflexe pris avec les modules du Registry. Ajoutez version à un appel dont la source est un dossier, et l'init refuse la configuration en expliquant pourquoi il a changé d'interprétation :

Error: Invalid registry module source address
Failed to parse module registry address: can't use local directory
"../commun/gabarit" as a module registry address.
Terraform assumed that you intended a module registry source address because
you also set the argument "version", which applies only to registry modules.

Un module local n'a pas de version, il a l'état de son dossier à cet instant. C'est précisément ce qui pousse, au delà de deux ou trois consommateurs, vers un module versionné par Git ou par le Registry.

Un source piloté par une variable, depuis la 1.15

Section intitulée « Un source piloté par une variable, depuis la 1.15 »

Longtemps, source n'acceptait qu'une chaîne littérale, ce qui interdisait de paramétrer l'emplacement d'une bibliothèque. La 1.15 ouvre une brèche étroite : « The source attribute can reference constant input variables and local values. Any input variable referenced in source must declare const = true ». Sans ce mot-clé, l'init refuse, et son message est instructif :

Error: Unknown module source
on main.tf line 13, in module "gabarit":
13: source = var.bibliotheque
Only literal values and const variables can be evaluated during init.

La raison est structurelle : l'init s'exécute avant toute évaluation de variables ordinaires, il ne peut donc lire que ce qui est constant. La forme acceptée déclare la contrainte :

variable "bibliotheque" {
type = string
const = true
default = "../commun/gabarit"
}
module "gabarit" {
source = var.bibliotheque
etiquette = "nord"
}

L'init passe alors normalement, et modules.json enregistre le chemin résolu, pas la variable. Utile pour un dépôt de labs ou un monorepo dont la bibliothèque change de place selon le contexte, à réserver aux cas où ce besoin est réel : un source indirect coûte en lisibilité ce qu'il gagne en souplesse.

Le mécanisme est identique, le couplage ne l'est pas. Un module intégré au projet, en ./modules/gabarit, suit le même commit que le code qui l'appelle : les modifier ensemble garantit qu'ils restent cohérents. Un module posé hors du projet perd cette garantie, une modification se propage immédiatement à tous ses consommateurs, y compris celui dont vous avez oublié l'existence.

AspectModule intégré (./modules/)Module partagé (../commun/)
Chargementlu sur placelu sur place, identique
Réutilisationun seul projetplusieurs projets
Versionnementmême commit que le projetaucun, l'état du dossier
Propagation d'un changementmaîtrisée par le commitimmédiate, chez tous
Cas d'usagemodule spécifique au projetbibliothèque d'équipe

La documentation officielle répartit d'ailleurs les deux mécanismes sans détour : les chemins locaux « for closely-related modules used primarily for the purpose of factoring out repeated code elements », et le registre « for modules intended to be shared by multiple calling configurations ». Un module partagé entre projets emploie donc l'outil du premier cas pour servir le second, ce qui tient un certain temps, puis cesse.

Le module partagé est ainsi un excellent point de départ, et une mauvaise destination finale. Il convient parfaitement à un monorepo dont tous les projets avancent ensemble, comme dans séparer dev, staging et prod. Dès que deux équipes veulent des rythmes différents, le versionnement devient nécessaire, arbitrage détaillé dans monorepo ou un repo par stack.

Le fichier à ouvrir en premier est toujours le même, .terraform/modules/modules.json : il dit quel dossier Terraform lit vraiment, ce qui tranche la moitié des questions ci-dessous sans la moindre hypothèse.

SymptômeCause probableCorrection
Module not installed, « not yet installed »aucun init joué, ou modules.json suppriméterraform init
Module not installed, « local cache directory ... could not be read »le module a été déplacé ou renommécorriger source, puis terraform init
Invalid module source addresspréfixe ./ ou ../ absentajouter le préfixe, comme le suggère le message
Unreadable module directorychemin préfixé mais inexistantvérifier avec ls ../commun/gabarit
Invalid registry module source addressargument version sur une source localeretirer version
Local module path escapes module packagesource absolue sur un module qui appelle un voisinrepasser en chemin relatif
Unknown module sourcesource référençant une variable sans const = trueajouter const = true à la variable
Modification du module non prise en compteDir pointe ailleurs que ce que vous éditezlire modules.json, puis terraform init

Le lab module local remet trois projets à brancher sur une bibliothèque partagée, dont l'un doit servir de contre-exemple en chemin absolu. Les tests ne lisent aucun .tf : ils ouvrent les trois modules.json, comparent les couples Source et Dir, et modifient le module partagé dans une copie pour exiger un plan à 2 sans init. Il se joue hors ligne.

  • Un chemin local doit commencer par ./ ou ../, c'est le préfixe qui définit le mécanisme.
  • Un module local n'est pas installé : Source et Dir sont identiques dans modules.json, hors du cache.
  • Modifier le contenu du module n'exige aucun init, le plan suivant le voit.
  • Seul un changement de source, de place du dossier, ou un .terraform/ absent réclame un init, que terraform get sait aussi jouer.
  • Un source de module imbriqué est relatif au fichier qui l'écrit, et Dir le donne normalisé depuis la racine.
  • Un fichier embarqué dans le module se lit avec path.module, jamais par un chemin nu.
  • Le JSON du plan prouve ce que l'appelant a passé, et donc ce qui vient du défaut.
  • Un chemin absolu devient un paquet en file://, recopié dans .terraform/modules/, et casse tout module imbriqué appelé en relatif.
  • L'argument version ne s'applique qu'aux modules de registre, pas à un dossier.
  • Un source peut référencer une variable depuis la 1.15, à condition qu'elle déclare const = true.
  • Chaque projet garde son state : mêmes adresses, aucune collision.

Les questions ci-dessous portent sur ce qui casse en pratique : le source refusé pour une histoire de préfixe, l'init qu'on relance pour rien, et le chemin absolu qui semblait plus sûr.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn