Aller au contenu
Infrastructure as Code medium

Les fonctions Terraform : transformer et composer les valeurs

60 min de lecture

logo terraform

Comment construire le nom lab03-web-0 à partir de variables séparées ? Comment lire un fichier cloud-init et l'encoder en base64 ? Comment découper un réseau 10.10.0.0/16 en sous-réseaux ? Ces transformations reviennent dans chaque projet Terraform.

Terraform embarque 238 fonctions intégrées en version 1.15 (le décompte exact de votre version s'obtient avec terraform metadata functions -json), directement dans HCL, sans import ni module externe. Elles ne modifient pas l'infrastructure, elles transforment des valeurs : construire un nom avec format(), découper un réseau avec cidrsubnet(), fusionner des maps avec merge(), lire un fichier avec file(). Elles s'utilisent dans les locals, les outputs et les attributs de ressources.

Tous les exemples de ce guide ont été exécutés sur Terraform v1.15.4 avant publication, résultats compris. C'est nécessaire : plusieurs fonctions se comportent autrement que l'intuition ne le suggère. element() hors bornes, lookup() sans valeur par défaut et l'échappement de templatefile() sont trois pièges qui n'apparaissent qu'à l'exécution.

  • terraform console : tester une fonction en une seconde, sans projet
  • Par type de donnée : chaînes, nombres, listes, maps, fichiers
  • Réseau IP : cidrsubnet, cidrhost, cidrnetmask
  • Robustesse : try, can, coalesce, one
  • Données sensibles : sensitive, nonsensitive
  • Fonctions définies par les providers : la syntaxe provider::
  • Quand une fonction est évaluée : au plan ou à l'apply, et pourquoi ça compte
  • Terraform installé (installer Terraform). Les fonctions de ce guide existent depuis longtemps ; seules les fonctions de provider exigent Terraform 1.8 ou plus récent, et templatestring 1.9 ou plus récent.
  • Variables et locals maîtrisés (variables, locals).

Sans la console, vérifier une expression coûte un aller-retour complet : écrire la ligne dans un fichier, lancer terraform plan, attendre l'évaluation de toute la configuration, puis lire un message qui désigne la ligne fautive sans jamais montrer la valeur produite. Sur une expression imbriquée, on répète l'opération plusieurs fois avant de comprendre où la donnée dérape.

terraform console supprime cet aller-retour. C'est un interpréteur interactif : vous tapez une expression, il affiche la valeur obtenue. Le gain n'est pas seulement la vitesse, c'est de voir la donnée au lieu de la deviner à partir d'un message d'erreur.

Trois usages reviennent en permanence :

  • Vérifier ce que rend une fonction avant de l'écrire, en particulier sur les fonctions au comportement contre-intuitif présentées dans ce guide.
  • Lever un doute sur un type quand un provider refuse une valeur. La console ne se contente pas d'afficher le contenu, elle affiche le type dans sa notation : tolist([...]) pour une liste typée, toset([...]) pour un ensemble, des crochets nus pour un tuple, des accolades pour un objet.
  • Reproduire un cas limite (liste vide, clé absente, index hors bornes) pour observer l'erreur exacte, sans toucher à l'infrastructure.

Ce dernier point mérite d'être souligné : la console évalue des expressions, elle ne crée rien. Aucune ressource n'est créée, modifiée ni détruite, ce qui autorise à y tester n'importe quel cas tordu sans conséquence.

terraform console fonctionne dans un répertoire vide, sans aucun terraform init. C'est ce qui en fait un réflexe praticable : nul besoin de préparer un projet pour tester une idée.

Fenêtre de terminal
echo 'upper("lab06-vm")' | terraform console
"LAB06-VM"

La différence de notation entre un tuple et une liste typée se constate directement :

Fenêtre de terminal
echo 'tolist(["x","y"])' | terraform console
tolist([
"x",
"y",
])

En mode interactif :

Fenêtre de terminal
terraform console
> format("%s-%s.qcow2", "dev", "lab06-vm")
"dev-lab06-vm.qcow2"
> min(256, 512, 1024)
256
> exit

L'initialisation n'est requise que si l'expression référence un provider ou un module : une fonction de provider (provider::…), une data source, une ressource. Pour les fonctions intégrées, un répertoire vide suffit.

Pour savoir si une fonction existe dans votre version, interrogez la liste des signatures plutôt que la documentation :

Fenêtre de terminal
terraform metadata functions -json | jq -r '.function_signatures | keys[]' | wc -l
238

Attention à la portée de cette commande : elle ne retourne que les fonctions du langage. Les fonctions apportées par un provider n'y figurent pas, il faut alors lire terraform providers schema -json. Ne concluez donc pas qu'une fonction de provider n'existe pas au motif qu'elle est absente de cette liste.

Les fonctions de chaînes servent presque toujours au même besoin : produire un identifiant conforme à ce qu'attend le provider. Toutes retournent une nouvelle chaîne, aucune ne modifie la valeur d'origine.

format, construire une chaîne avec des placeholders

Section intitulée « format, construire une chaîne avec des placeholders »

format() applique un gabarit dans lequel chaque marqueur % est remplacé par un argument, pris dans l'ordre. Il est plus lisible qu'une interpolation multi-parties dès trois morceaux, et il sait imposer un remplissage que ${...} ne produit pas.

locals {
disk_name = format("%s-%s.qcow2", var.environment, var.vm_name)
# "dev-lab06-vm.qcow2"
# Remplissage a zero : indispensable pour un tri correct plus loin
index_padded = format("%03d", 7) # "007"
}

Placeholders courants : %s (string), %d (entier), %f (flottant), %v (valeur quelconque). La forme %03d complète à gauche avec des zéros.

Plusieurs ressources cloud refusent les majuscules dans un nom, alors qu'une variable saisie par un humain en contient souvent. Appliquer lower() à l'entrée évite un échec au moment de l'apply.

locals {
vm_name_lower = lower(var.vm_name) # "lab06-vm"
}

replace(chaîne, motif, remplacement) échange toutes les occurrences. Si le motif est encadré par des barres obliques ("/^web-/"), Terraform l'interprète comme une expression régulière ; sinon la recherche est littérale.

locals {
hostname_replaced = replace(var.vm_name, "-", "_") # "lab06_vm"
sans_prefixe = replace("web-01", "/^web-/", "") # "01"
}

Le cas d'usage réel n'est pas le littéral mais la sortie d'un fichier lu avec file(), qui se termine presque toujours par un saut de ligne invisible et casse la comparaison attendue.

locals {
cle_ssh = trimspace(file("${path.module}/id_ed25519.pub"))
}

split(séparateur, chaîne) produit une liste, join() fait le trajet inverse. C'est la porte d'entrée habituelle quand une valeur arrive en CSV, par exemple depuis une variable d'environnement de CI qui ne transporte que du texte.

Fenêtre de terminal
echo 'split(",", "dev,staging,prod")' | terraform console
tolist([
"dev",
"staging",
"prod",
])
locals {
joined_envs = join(", ", ["dev", "staging", "prod"])
# "dev, staging, prod"
}

Terraform manipule un seul type number, qui accepte les décimales : une division rend donc un flottant, alors que la plupart des attributs de dimensionnement exigent un entier.

Le choix n'est pas cosmétique : pour une capacité de disque ou de mémoire, arrondir vers le bas produit une ressource trop petite, alors que ceil() garantit qu'elle reste suffisante.

locals {
memory_gib = ceil(512 / 1024) # ceil(0.5) donne 1
disk_gb = floor(3.758) # donne 3
}

Combinées, elles contraignent une valeur dans un intervalle : min(max(var.memory, 512), 8192) refuse de descendre sous 512 Mo et de dépasser 8 Go. Pour les appliquer à une liste, il faut la développer avec ... :

Fenêtre de terminal
echo 'min([256, 512, 1024]...)' | terraform console
256

Les fonctions de listes préparent les données qui alimentent count et for_each : count réclame un entier, for_each réclame un set ou une map, et une liste brute ne convient telle quelle ni à l'un ni à l'autre.

length() retourne le nombre d'éléments d'une liste, d'un set ou d'une map, et le nombre de caractères d'une chaîne.

locals {
environments = ["dev", "staging", "prod"]
env_count = length(local.environments) # 3
}

element(liste, index) reboucle modulo la longueur de la liste quand l'index dépasse. C'est le piège le plus répandu de cette fonction : elle ne retombe pas sur le premier élément, elle calcule index % length.

Fenêtre de terminal
echo 'element(["dev","staging","prod"], 5)' | terraform console
"prod"

L'index 5 sur trois éléments donne 5 % 3 = 2, donc "prod". L'index 3 donnerait bien "dev", mais uniquement parce que 3 est un multiple exact de la longueur.

Deux comportements à connaître, vérifiés en 1.15.4 :

AppelRésultat
element(["dev","staging","prod"], 5)"prod" (rebouclage modulo)
element(["dev","staging","prod"], -1)"prod" (dernier élément)
element([], 0)Erreur : la fonction refuse une liste vide

Le cas de la liste vide n'est pas théorique : il survient dès qu'un split() ne trouve rien. Pour un accès strict qui échoue sur un index hors bornes, utilisez la syntaxe liste[index].

sort() trie lexicographiquement, jamais numériquement. Sur des nombres, le résultat surprend :

Fenêtre de terminal
echo 'sort([3, 10, 2])' | terraform console
tolist([
"10",
"2",
"3",
])

"10" passe avant "2" parce que la comparaison se fait caractère par caractère. Pour trier des nombres, il faut les remplir à gauche avant de trier, puis reconvertir :

Fenêtre de terminal
echo '[for s in sort([for x in [3,10,2] : format("%09d", x)]) : tonumber(s)]' | terraform console
[
2,
3,
10,
]

Une précision qui évite un faux remède : convertir simplement les nombres en chaînes, avec sort([for x in liste : tostring(x)]), reproduit exactement le tri lexicographique fautif. Le résultat reste ["10", "2", "3"]. Seul le remplissage à largeur fixe par format("%09d", x) donne un ordre numérique correct.

contains(liste, valeur) retourne un booléen. C'est le socle des blocs validation : il refuse une valeur hors de la liste autorisée dès le plan, plutôt que de laisser le provider échouer au milieu de l'apply.

variable "environnement" {
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environnement)
error_message = "L'environnement doit valoir dev, staging ou prod."
}
}

toset() convertit une liste en set : ni doublon, ni position. C'est ce qui permet à for_each d'utiliser la valeur de chaque élément comme clé d'instance dans le state, à la place d'un index numérique fragile.

Fenêtre de terminal
echo 'toset(["b","a","a","c"])' | terraform console
toset([
"a",
"b",
"c",
])

Ces trois fonctions deviennent incontournables dès que l'on combine for_each et modules.

Fenêtre de terminal
echo 'flatten([["a","b"],["c"]])' | terraform console # ["a","b","c"]
echo 'zipmap(["a","b"],[1,2])' | terraform console # {"a"=1, "b"=2}
echo 'one(["seul"])' | terraform console # "seul"
echo 'one([])' | terraform console # null

flatten() aplatit les listes imbriquées produites par une double boucle for. zipmap() construit une map à partir de deux listes parallèles. one() extrait l'élément unique d'une liste, ou rend null si elle est vide : c'est la façon propre de lire une ressource créée avec count = var.actif ? 1 : 0.

Une map associe des clés à des valeurs. C'est la structure de référence pour les tags et pour les tables de correspondance entre un environnement et un dimensionnement.

Les clés sont retournées triées par ordre lexicographique, ce qui garantit que les deux listes gardent le même ordre d'une exécution à l'autre.

variable "tags" {
type = map(string)
default = { projet = "demo", equipe = "devops", auteur = "terraform" }
}
locals {
tag_keys = keys(var.tags) # ["auteur", "equipe", "projet"]
tag_values = values(var.tags) # ["terraform", "devops", "demo"]
}

merge() combine plusieurs maps. En cas de clé dupliquée, la dernière map gagne. C'est ce qui autorise une surcharge ciblée sans réécrire le socle de tags communs.

locals {
tags_merged = merge(var.tags, { env = var.environment })
# {auteur="terraform", env="dev", equipe="devops", projet="demo"}
}

lookup() sans troisième argument lève une erreur si la clé est absente, il ne retourne jamais null. La forme à deux arguments est de surcroît dépréciée depuis Terraform 0.7, parce qu'elle équivaut alors à la syntaxe native map[clé].

Fenêtre de terminal
echo 'lookup({a = "alpha"}, "zz")' | terraform console
Error: Invalid function argument
the given object has no attribute "zz"

Passez donc toujours la valeur de repli :

locals {
instance_size = lookup({
dev = "small"
staging = "medium"
prod = "large"
}, var.environment, "small")
}

Ces deux fonctions traitent le cas d'une valeur dont la forme n'est pas garantie : un attribut optionnel, une structure venant d'un jsondecode(), une entrée utilisateur.

  • can(expression) rend un booléen : l'expression a-t-elle pu être évaluée ?
  • try(expr1, expr2, …) rend la première expression qui s'évalue sans erreur.
Fenêtre de terminal
echo 'can(tonumber("abc"))' | terraform console # false
echo 'try(tonumber("abc"), 0)' | terraform console # 0
echo 'try({a=1}.b, "absent")' | terraform console # "absent"

can() sert principalement dans un bloc validation, où il transforme une erreur d'évaluation en simple false :

variable "port" {
type = string
validation {
condition = can(tonumber(var.port))
error_message = "Le port doit être un nombre."
}
}

La documentation officielle assortit try() d'un avertissement qu'il faut prendre au sérieux : en abuser produit une configuration difficile à comprendre et à maintenir, parce qu'elle masque les erreurs au lieu de les traiter. Réservez-la aux cas où l'absence de valeur est légitime.

sensitive() marque une valeur comme sensible, ce qui la masque dans les sorties de la CLI. nonsensitive() retire ce marquage.

Fenêtre de terminal
echo 'sensitive("secret")' | terraform console # (sensitive value)
echo 'nonsensitive(sensitive("secret"))' | terraform console # "secret"

Le marquage se propage : toute expression dérivée d'une valeur sensible devient sensible à son tour. C'est utile, mais cela peut casser une configuration qui fonctionnait, notamment parce qu'une valeur sensible est interdite en argument de for_each. nonsensitive() existe pour ces cas, et doit rester exceptionnel.

Marquer une valeur ne la retire pas du state, où elle reste en clair. Le sujet est traité en détail dans gérer les données sensibles.

Ces fonctions calculent des plages d'adresses à partir d'un préfixe, au lieu de les écrire en dur. C'est ce qui permet de découper un réseau une seule fois et de dériver tout le reste.

Fenêtre de terminal
echo 'cidrsubnet("10.10.0.0/16", 8, 3)' | terraform console # "10.10.3.0/24"
echo 'cidrhost("10.10.3.0/24", 10)' | terraform console # "10.10.3.10"
echo 'cidrnetmask("10.10.3.0/24")' | terraform console # "255.255.255.0"
  • cidrsubnet(préfixe, bits_ajoutés, numéro) découpe un réseau en sous-réseaux. Ici on ajoute 8 bits au /16 pour obtenir des /24, et on prend le quatrième (numéro 3, en comptant depuis zéro).
  • cidrhost(préfixe, numéro) calcule l'adresse d'un hôte dans un réseau : parfait pour fixer l'adresse d'une passerelle ou d'un serveur.
  • cidrnetmask(préfixe) convertit un préfixe en masque décimal, format que réclament encore certaines configurations réseau.
Fenêtre de terminal
echo 'formatdate("YYYY-MM-DD", "2026-07-24T10:00:00Z")' | terraform console # "2026-07-24"
echo 'timeadd("2026-07-24T10:00:00Z", "24h")' | terraform console # "2026-07-25T10:00:00Z"
echo 'sha256("terraform")' | terraform console # "94dc3ea5…"
echo 'base64sha256("terraform")' | terraform console # "lNw+pXch…"

filesha256() calcule l'empreinte d'un fichier sans le charger en mémoire : c'est la façon habituelle de déclencher le remplacement d'une ressource quand un script change.

Ces fonctions font entrer dans la configuration du contenu qui n'a rien à faire dans du HCL : une clé SSH, un script cloud-init, un certificat.

Un chemin relatif nu est résolu depuis le répertoire d'exécution de Terraform, pas depuis le répertoire du module. C'est la raison pour laquelle il faut préfixer par path.module dans tout module destiné à être appelé ailleurs.

Vérification : un module contenant file("data.txt") lit le data.txt de la racine appelante, alors que file("${path.module}/data.txt") lit bien le sien.

locals {
# Correct dans un module : le fichier suit le module
cle_ssh = trimspace(file("${path.module}/id_ed25519.pub"))
}

Le fichier doit exister au moment du plan et être versionné avec le code : un fichier produit par l'apply d'une autre ressource ne peut pas être lu ainsi. file() suppose de l'UTF-8 valide et rejette un contenu binaire, pour lequel il faut filebase64().

templatefile() lit un fichier puis remplace les marqueurs ${...} par les valeurs de la map passée en second argument. Le template n'a accès qu'à ces variables, jamais aux var.* ou local.* du module.

locals {
user_data = templatefile("${path.module}/cloud-init.yml.tpl", {
hostname = local.hostname
ssh_key = trimspace(file("${path.module}/id_ed25519.pub"))
})
}

Un template correct s'écrit donc ainsi :

#!/usr/bin/env bash
echo "home=$HOME" # $ litteral : rien a echapper
echo "date=$(date)" # substitution shell : rien a echapper
echo "hote=${hostname}" # interpolation Terraform
echo "litteral=$${AUTRE}" # rend le texte ${AUTRE}, non interprete par Terraform

templatestring, pour un template obtenu dynamiquement

Section intitulée « templatestring, pour un template obtenu dynamiquement »

templatestring() (Terraform 1.9 et plus) rend un template qui arrive d'une autre source : une variable, un fichier lu ailleurs, un attribut de ressource.

Elle refuse un template écrit en littéral. Terraform répond alors : « templatestring is only for rendering templates retrieved dynamically from elsewhere, and so does not support providing a literal template ». Pour un littéral, utilisez une expression de template classique.

variable "gabarit" {
type = string
default = "Bonjour $${nom}, tu as $${age} ans"
}
output "message" {
value = templatestring(var.gabarit, { nom = "alice", age = 30 })
# "Bonjour alice, tu as 30 ans"
}

Certains providers transportent le contenu dans un champ qui n'accepte pas les sauts de ligne.

locals {
user_data_b64 = base64encode(local.user_data)
}

On ne peut pas définir ses propres fonctions en HCL, mais un provider peut en exposer. Depuis Terraform 1.8, elles s'appellent avec la syntaxe provider::<nom_local>::<fonction>.

Le point qui piège : <nom_local> est le nom déclaré dans required_providers, pas le nom du provider. Si vous le renommez, le préfixe change avec lui.

Le provider intégré terraform permet de tester tout cela sans le moindre téléchargement :

terraform {
required_version = ">= 1.8"
required_providers {
tfcore = { source = "terraform.io/builtin/terraform" }
}
}
output "expr" { value = provider::tfcore::encode_expr([1, "deux"]) }
output "tfvars" { value = provider::tfcore::encode_tfvars({ region = "eu-west-1", n = 3 }) }
output "decode" { value = provider::tfcore::decode_tfvars("n = 3\nregion = \"eu-west-1\"\n") }

Résultat après terraform apply :

decode = { "n" = 3, "region" = "eu-west-1" }
expr = "[1, \"deux\"]"
tfvars = "n = 3\nregion = \"eu-west-1\"\n"

decode_tfvars préserve les types : n ressort en nombre, pas en chaîne. Avec le nom local tfcore, écrire provider::terraform::encode_expr(1) échoue sur Unknown provider, ce qui confirme que le préfixe suit bien la déclaration.

Toutes les erreurs de fonctions ne tombent pas au plan. Une fonction appliquée à une valeur encore inconnue au moment du plan, parce qu'elle provient d'une ressource non encore créée, n'est évaluée qu'à l'apply. L'erreur survient alors après que des ressources ont été créées.

La démonstration tient en quelques lignes :

resource "random_pet" "x" { length = 2 }
# random_pet.x.id est inconnu au plan : la fonction n'est pas evaluee maintenant
output "casse" { value = tonumber(random_pet.x.id) }

Le terraform plan réussit et rend le code 0. Le terraform apply échoue sur Invalid function argument, et terraform state list montre que random_pet.x a bien été créé. Corriger l'expression ne suffit donc pas toujours : il faut ensuite réconcilier l'état partiel.

C'est la raison pour laquelle une validation explicite, par un bloc validation ou une precondition, vaut mieux qu'une fonction qui échouera peut-être trop tard.

Le bon angle de recherche est le type de donnée manipulé : quand une expression échoue, c'est presque toujours parce que la fonction n'accepte pas le type reçu.

CatégorieFonctions clés
Chaînesformat, upper, lower, replace, trimspace, split, join, substr, startswith, endswith
Nombresceil, floor, min, max, abs, pow, signum, parseint
Listeslength, element, sort, contains, toset, flatten, concat, distinct, range, one
Mapskeys, values, merge, lookup, zipmap, tomap
Fichiersfile, templatefile, templatestring, base64encode, filebase64, fileexists
Réseau IPcidrsubnet, cidrhost, cidrnetmask, cidrsubnets
Date et heuretimestamp, timeadd, timecmp, formatdate
Hachagesha256, filesha256, base64sha256, bcrypt, uuid
Robustessetry, can, coalesce, coalescelist, nonsensitive, sensitive
Encodage et typesjsonencode, jsondecode, yamlencode, tostring, tonumber, tobool

La référence complète est dans la documentation officielle des fonctions Terraform.

Les erreurs de fonctions se lisent mal, parce que le message désigne la fonction appelée et non la donnée qui pose problème. Le tableau ci-dessous part donc du symptôme observé pour remonter à la cause. Les deux premières lignes concernent le même message d'erreur, Invalid function argument : ce qui les distingue est le moment où il apparaît, au plan ou à l'apply, et c'est ce moment qui désigne le correctif.

SymptômeCause probableSolution
Invalid function argument sur lookupClé absente et aucune valeur de repliPasser un troisième argument : lookup(map, clé, défaut)
Invalid function argument au moment de l'applyFonction appliquée à une valeur inconnue au planValider en amont avec can() ou une precondition
Call to unknown functionFaute de frappe, ou fonction de provider non déclaréeVérifier terraform metadata functions -json, et required_providers pour un préfixe provider::
Unknown provider sur provider::x::fLe nom local ne correspond pas à required_providersLe préfixe suit le nom local, pas le nom du provider
Script cloud-init cassé, $$ visibleTous les $ ont été échappésN'échapper que ${ en $${
Tri de nombres incorrectsort() trie lexicographiquementRemplir à largeur fixe avec format("%09d", x) avant de trier
Un plan annonce un changement à chaque exécutionFonction impure (timestamp, uuid, bcrypt)Sortir la valeur du state ou utiliser ignore_changes
element() rend une valeur inattendueRebouclage modulo sur index hors bornesUtiliser liste[index] pour un accès strict
  1. terraform console fonctionne sans init dans un répertoire vide : testez toujours avant d'écrire.
  2. element() reboucle modulo, elle ne retombe pas sur le premier élément, et refuse une liste vide.
  3. lookup() sans valeur de repli lève une erreur, et sa forme à deux arguments est dépréciée.
  4. sort() trie lexicographiquement : format("%09d", x) avant de trier pour des nombres.
  5. Dans templatefile, n'échappez que ${ : tout échapper casse le script.
  6. Un chemin nu dans file() part du répertoire d'exécution, d'où path.module dans un module.
  7. Une fonction sur une valeur inconnue au plan n'échoue qu'à l'apply, après création partielle.
  8. Les fonctions de provider s'appellent provider::<nom_local>::<fonction>, le nom local venant de required_providers.

Les questions ci-dessous portent presque toutes sur un comportement contre-intuitif : une fonction qui reboucle au lieu d'échouer, une autre qui échoue au lieu de rendre null, un tri qui range 10 avant 9. Chaque réponse donne la commande de vérification correspondante, à rejouer dans terraform console.

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