Dans l’étape précédente, l’utilitaire Packer nous a permis de préparer un template de référence, notre golden image sur une base de Rocky Linux 10.
Il est temps de passer au second outil du même éditeur que Packer : Terraform.
Cliquez sur l'image pour l'agrandir.
Présent dans le catalogue de HashiCorp depuis 2014, Terraform est l’outil qui a popularisé le terme Infrastructure As Code au-delà même du monde cloud. L’infrastructure (VMs, réseaux, DNS, buckets…) est décrite dans des fichiers texte versionnés en HCL2, le même langage que Packer.
En 2023 HashiCorp fait passer Terraform sous licence BSL (Business Source License), qui n’est pas reconnue comme une licence open source. Ce changement a entrainé un fork communautaire, OpenTofu (Linux Foundation), qui est compatible à ce jour avec les mêmes fichiers .tf.
Mon précédent Cookbook exploitait d’ailleurs cette version dérivée de Terraform. Étant donné qu’en l’état, le code fonctionne avec l’un comme l’autre, je suis revenu à Terraform, puisque la licence BSL autorise sans contrainte le déploiement en production, y compris en milieu professionnel. Elle restreint principalement la création d'offres commerciales concurrentes.
Libre à chacun d’utiliser la version de son choix, la force de cet écosystème restant la mise à disposition de plus de 3000 providers. Ces derniers permettent la traduction de la grammaire HCL vers les API de nombreuses plateformes, comme AWS, Azure, vSphere… Et dans notre cas, Xen Orchestra (XO). Vates, l’éditeur de XCP-ng et de XO, dispose de son provider communautaire autorisant l’usage de Terraform (ou OpenTofu) pour déployer des ressources.
Le principe de base derrière Terraform et de l’infrastructure as code en général est de décrire l'état cible, pas les actions.
Contrairement à un script qui énumère des choses à faire (« crée la VM, puis attache le disque… »), Terraform est déclaratif : on décrit l'état final souhaité (« il existe une VM prdk8sgru501, 4 vCPU, 6 Go, sur ce réseau »), et l'outil calcule lui-même les opérations pour y arriver.
Création, modification, ou rien du tout si l'existant est déjà conforme. C'est ce qui rend les choses rejouable sans risque (idempotent).
Pour faire ce calcul, Terraform maintient un fichier d'état (terraform.tfstate) : la photographie de ce qu'il a réellement créé (IDs des VMs dans XO, disques, MAC…).
À chaque exécution, il compare trois choses : le code (l’intention), l’état (ce qu’il croit exister) et la réalité (interrogée via l’API).
L'écart entre l’état et la réalité devient un plan d'exécution. Celui-ci est affiché avant toute modification.
Cliquez sur l'image pour l'agrandir.
Le plus gros risque avec Terraform est les modifications effectuées en dehors d’une exécution avec l’outil.
Si des assets déployés et configurés avec Terraform sont manipulés directement par des opérateurs, alors le fichier d’état de Terraform devient erroné et peut pousser ce dernier à réaliser des actions inutiles ou en conflit avec l’état réel des assets.
C’est pourquoi, lorsqu’on utilise une logique de IAC, on doit maintenir les fichiers utilisés et conserver précieusement les tfstate associés. Il faut éviter de réaliser des modifications manuellement directement sur les infrastructures concernées.
Ou bien, on admet que le IAC est utilisé uniquement pour la mise en œuvre, mais qu’on ne l’utilise plus par la suite pour le maintien en condition opérationnelle. Ce n’est pas mon choix préféré, mais, tant qu’on assume et qu’on maitrise cela, c’est une manière de faire parmi d’autres.
Il est important de noter que si plusieurs personnes doivent manipuler des actifs sous gestion Terraform, il est essentiel que les fichiers tfstate puissent être partagés et mis à jour lors de chaque opération, que ce soit par un ou plusieurs utilisateurs. Dans ce cas, il existe des outils d’hébergement et de suivi des tfstate, comme Terraform Cloud, pour travailler sur une logique IAC au sein d’une ou de plusieurs équipes.
L’installation de Terraform n’est absolument pas complexe, comme pour Packer, il suffit de récupérer le binaire compatible avec son système, puis de s’assurer qu’il peut être appelé à partir de n’importe où.
Des solutions de gestionnaire de package comme brew sous macOS peuvent également être utilisées.
Comme toujours il est important de retenir une arborescence maintenable et compréhensible pour déclarer ses fichiers Terraform.
Plusieurs choix sont possibles, et c’est à chacun d’adapter son arborescence à ses besoins et ce qu’il estime être le plus optimisé pour lui. Il est crucial de rester fidèle à cette structure et de s’assurer que toute personne manipulant le code la respecte. Il faut suivre la logique de déclaration adoptée.
On retrouve néanmoins toujours un vocabulaire commun :
Peu importe le nombre de fichiers exploités ou le découpage retenu pour décrire ses assets, Terraform charge tous les fichiers *.tf du dossier depuis lequel il est appelé et les fusionne.
Par convention et pour une meilleure lisibilité, on prend néanmoins l’habitude de créer plusieurs fichiers. Le découpage retenu ici n’est pas une obligation technique, mais s’appuie sur une logique souvent choisie dans la communauté (mais rien ne vous empêche de faire autrement).
| Fichier | Rôle |
|---|---|
| versions.tf | Le contrat d'outillage : version minimale de Terraform (>= 1.6), providers épinglés (vatesfr/xenorchestra ~> 0.39, hashicorp/local ~> 2.5) et bloc provider (TLS, retry_mode = "backoff"). L'authentification XO n'y figure pas : elle passe par XOA_URL/XOA_TOKEN en variables d'environnement. |
| variables.tf | Les entrées du déploiement : déclaration, description et type de chaque variable (pools, hosts, zones, nodes, DNS…). Ne contient aucune valeur d'infrastructure — seulement leur définition, renforcée par des blocs validation qui rejettent une saisie incohérente (zone inconnue, hôte non déclaré, nœud DMZ sans réseau DMZ…) dès le plan. |
| terraform.tfvars | Les valeurs réelles de ces variables : la topologie (pools, hôtes, SR locaux) et la carte des 11 nœuds (rôle, groupe Ansible, zone, hôte de placement, IP, vCPU/RAM/disque, flag traefik). C'est le seul fichier à éditer pour ajouter/déplacer/redimensionner un nœud (aucun secret dedans). |
| terraform.tfvars.example | Le modèle commenté de terraform.tfvars, avec placeholders REMPLACER_* : documente la topologie attendue et les commandes d'export XOA_URL/XOA_TOKEN pour repartir de zéro. |
| main.tf | Le cœur : les data sources qui résolvent les noms XO en IDs (pool → template, réseaux LAN/DMZ, SR, hôtes), puis un unique bloc module "node" avec for_each = var.nodes — chaque entrée de la carte nodes devient une instance du module, câblée sur le bon pool/réseau/SR selon sa zone et son hôte. |
| locals.tf | Les valeurs calculées : liste des hôtes réellement utilisés, tags XO par nœud (node_tags, miroir des groupes), et surtout la structure de l'inventaire Ansible construite à partir de var.nodes — hôtes déclarés dans les groupes de rôle, groupes transverses (clu_k8s_xkub, os_lin_r10, sys_net_lan, sys_net_web) assemblés par références children, groupes applicatifs Traefik dérivés du flag traefik. |
| outputs.tf | Les sorties : la ressource local_file qui écrit hosts.yml dans le dépôt Ansible à chaque apply (via yamlencode(), YAML garanti valide), plus deux outputs de contrôle (deployed_nodes = hostname → IP, vm_ids = IDs XO). |
| modules/k8s-node/ | Le "moule à VM", instancié 11 fois. main.tf : l'unique ressource xenorchestra_vm (clone du template, vCPU/RAM/disque, affinity_host pour le placement, tags XO, injection cloud-init). variables.tf / outputs.tf / versions.tf : le contrat d'entrée/sortie du module. |
| templates/cloud-init/user-data.tftpl | Gabarit cloud-config rendu par templatefile() pour chaque VM : hostname + FQDN. Volontairement minimal — la clé SSH ansible-windows est déjà dans le template Packer, on ne la redéploie pas. |
| templates/cloud-init/network-config.tftpl | Gabarit network-config (NoCloud v1) : IP statique/préfixe, passerelle de la zone, DNS et search domain sur l'interface enX0. C'est ici que les IP fixes promises en phase 1 prennent vie. |
| terraform.tfstate (+ .backup) | L'état : ce que Terraform a réellement créé (IDs XO, disques…). Fichier critique et non versionné — le perdre, c'est perdre le lien entre le code et les VMs existantes. Ne jamais l'éditer à la main. |
| tfplan | Un plan sauvegardé (terraform plan -out=tfplan) : permet d'appliquer exactement ce qui a été revu (terraform apply tfplan). Artefact jetable, non versionné. |
Voici le contenu de chacun de ces fichiers.
terraform {
required_version = ">= 1.6.0"
required_providers {
xenorchestra = {
source = "vatesfr/xenorchestra"
version = "~> 0.39.0"
}
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
# Authentification Xen Orchestra fournie EXCLUSIVEMENT via l'environnement :
# XOA_URL = "wss://prdxcpxor501.coolcorp.priv:8443" (websocket, pas https)
# XOA_TOKEN = "<token XO>"
# Rien ne doit figurer ici ni dans git (cf. CLAUDE.md).
# `insecure` est passé explicitement : la variable d'env XOA_INSECURE n'est pas
# prise en compte de façon fiable par le provider 0.39.x.
# `retry_mode = "backoff"` : XAPI limite à 3 les migrations de stockage simultanées
# (TOO_MANY_STORAGE_MIGRATES) ; le provider réessaie au lieu d'échouer.
provider "xenorchestra" {
insecure = var.xoa_insecure
retry_mode = "backoff"
}
versions.tf
variable "xoa_insecure" {
description = "Désactive la vérification TLS vers Xen Orchestra (certificat auto-signé non approuvé par le poste). La variable d'environnement XOA_INSECURE n'est pas prise en compte de façon fiable par le provider 0.39.x, d'où ce passage explicite."
type = bool
default = true
}
variable "template_name" {
description = "Nom du template XCP-ng à cloner (construit en phase 1 Packer). Défaut pour tous les pools, surchargeable par pool."
type = string
default = "prdtplroc501"
}
variable "pools" {
description = <<-EOT
Réseaux et template PAR POOL XCP-ng (clé = name_label exact du pool).
Ne déclarer QUE les pools réellement utilisés (témoin : un seul pool).
lan_network_name : name_label du réseau LAN sur ce pool
dmz_network_name : name_label du réseau DMZ sur ce pool (requis si nœuds zone=dmz)
template_name : (optionnel) surcharge du template sur ce pool, sinon var.template_name
Le template doit EXISTER sur chaque pool listé (copie cross-pool dans XO).
EOT
type = map(object({
lan_network_name = string
dmz_network_name = optional(string, "")
template_name = optional(string)
}))
}
variable "hosts" {
description = <<-EOT
Hôtes XCP-ng utilisables (clé = name_label exact de l'hôte).
Stockage LOCAL par hôte → le SR appartient à l'hôte ; choisir l'hôte fixe le SR.
pool : clé dans var.pools à laquelle l'hôte appartient
sr_name : name_label du SR (local) où poser les disques des VMs de cet hôte
EOT
type = map(object({
pool = string
sr_name = string
}))
validation {
condition = alltrue([for h in var.hosts : contains(keys(var.pools), h.pool)])
error_message = "Chaque hosts[*].pool doit correspondre à une clé déclarée dans var.pools."
}
}
variable "network_interface" {
description = "Nom de l'interface réseau dans la VM, côté cloud-init (network-config). Sur le template Rocky 10 : enX0."
type = string
default = "enX0"
}
variable "dns_servers" {
description = "Serveurs DNS internes (zone coolcorp.priv)."
type = list(string)
default = ["192.168.10.30"]
}
variable "search_domain" {
description = "Domaine de recherche DNS et suffixe FQDN des nœuds."
type = string
default = "coolcorp.priv"
}
variable "zones" {
description = "Paramètres réseau par zone (passerelle + préfixe CIDR)."
type = map(object({
gateway = string
prefix = number
}))
default = {
lan = { gateway = "192.168.10.253", prefix = 24 }
dmz = { gateway = "192.168.5.253", prefix = 24 }
}
}
variable "nodes" {
description = <<-EOT
Carte des VMs à déployer (clé = hostname court).
PREMIER ESSAI : ne mettre qu'UNE entrée (VM témoin). Étendre ensuite aux 11 nœuds.
role : control-plane | worker | haproxy
ansible_group : k8s_controlplane_lan | k8s_workers_lan | k8s_workers_web | nlb_haproxy_lan | nlb_haproxy_web
zone : lan | dmz (doit être cohérente avec le suffixe du groupe)
host : clé dans var.hosts (détermine pool, SR et placement)
ip : IP statique (sans préfixe)
traefik : (optionnel, défaut false) le nœud portera une instance Traefik ;
l'ajoute au groupe applicatif k8s_traefik_lan ou k8s_traefik_web
selon sa zone (workers uniquement)
EOT
type = map(object({
role = string
ansible_group = string
zone = string
host = string
ip = string
cpus = number
memory_gb = number
disk_gb = number
traefik = optional(bool, false)
}))
validation {
condition = alltrue([for n in var.nodes : contains(["lan", "dmz"], n.zone)])
error_message = "Chaque nœud doit avoir zone = \"lan\" ou \"dmz\"."
}
validation {
condition = alltrue([for n in var.nodes : contains(
["k8s_controlplane_lan", "k8s_workers_lan", "k8s_workers_web", "nlb_haproxy_lan", "nlb_haproxy_web"],
n.ansible_group)])
error_message = "ansible_group invalide (valeurs attendues : k8s_controlplane_lan, k8s_workers_lan, k8s_workers_web, nlb_haproxy_lan, nlb_haproxy_web)."
}
validation {
condition = alltrue([for n in var.nodes : (n.zone == "dmz") == contains(["k8s_workers_web", "nlb_haproxy_web"], n.ansible_group)])
error_message = "Incohérence zone/groupe : les groupes *_web exigent zone = \"dmz\", les autres zone = \"lan\" (les groupes transverses sys_net_lan/sys_net_web sont dérivés des groupes de rôle)."
}
validation {
condition = alltrue([for n in var.nodes : !n.traefik || contains(["k8s_workers_lan", "k8s_workers_web"], n.ansible_group)])
error_message = "traefik = true n'est permis que sur les workers (k8s_workers_lan ou k8s_workers_web)."
}
validation {
condition = alltrue([for n in var.nodes : contains(keys(var.hosts), n.host)])
error_message = "Chaque node.host doit correspondre à une clé déclarée dans var.hosts."
}
validation {
condition = alltrue([for n in var.nodes : n.zone != "dmz" || try(var.pools[var.hosts[n.host].pool].dmz_network_name, "") != ""])
error_message = "Un nœud zone=dmz exige dmz_network_name défini sur le pool de son hôte."
}
}
variables.tf
# ===========================================================================
# Exemple de variables — COPIER en `terraform.tfvars` puis renseigner.
#
# Auth XO : NE PAS mettre ici. Exporter dans l'environnement avant terraform.
# L'URL est en WEBSOCKET (ws:// ou wss://), pas en https:// :
# PowerShell : $env:XOA_URL = "wss://prdxcpxor501.coolcorp.priv:8443"; $env:XOA_TOKEN = "<token>"
# Bash : export XOA_URL="wss://prdxcpxor501.coolcorp.priv:8443" XOA_TOKEN="<token>"
#
# Topologie XCP-ng : 3 pools vus par le même XO
# - pool "xcp-pool" : hôtes prdxcpsrv001 / prdxcpsrv002 / prdxcpsrv003 (template ICI)
# - pool "prdxcpsrv004" : hôte prdxcpsrv004
# - pool "prdxcpsrv005" : hôte prdxcpsrv005
# Stockage LOCAL par hôte (SR par hôte). PRÉREQUIS multi-pool : le template
# prdtplroc501 doit être COPIÉ sur chaque pool utilisé (copie cross-pool dans XO).
#
# Déploiement complet : 11 VMs réparties sur les 3 pools (HA : 3 CP sur 3 pools,
# chaque paire HAProxy sur 2 pools — cf. docs/ARCHITECTURE.md, décision D6).
# IPs conformes au plan d'adressage ARCHITECTURE.md (LAN 192.168.10.0/24 gw .253,
# DMZ 192.168.5.0/24 gw .253). Renseigner les name_label RÉELS (vus dans XO) des
# réseaux LAN/DMZ de chaque pool et du SR local de chaque hôte.
# ===========================================================================
# Certificat XO auto-signé non approuvé par le poste : désactive la vérification
# TLS du provider (XOA_INSECURE n'est pas fiable sur le provider 0.39.x).
xoa_insecure = true
pools = {
"xcp-pool" = { lan_network_name = "REMPLACER_LAN_xcp-pool", dmz_network_name = "REMPLACER_DMZ_xcp-pool" }
"prdxcpsrv004" = { lan_network_name = "REMPLACER_LAN_004", dmz_network_name = "REMPLACER_DMZ_004" }
"prdxcpsrv005" = { lan_network_name = "REMPLACER_LAN_005", dmz_network_name = "REMPLACER_DMZ_005" }
}
hosts = {
prdxcpsrv001 = { pool = "xcp-pool", sr_name = "REMPLACER_SR_LOCAL_srv001" }
prdxcpsrv002 = { pool = "xcp-pool", sr_name = "REMPLACER_SR_LOCAL_srv002" }
prdxcpsrv003 = { pool = "xcp-pool", sr_name = "REMPLACER_SR_LOCAL_srv003" }
prdxcpsrv004 = { pool = "prdxcpsrv004", sr_name = "Local-NVMe-Kingston-1TB-srv004" }
prdxcpsrv005 = { pool = "prdxcpsrv005", sr_name = "REMPLACER_SR_LOCAL_srv005" }
}
nodes = {
# --- Control plane (1 par pool = HA maximale) ---
prdk8sgru501 = { role = "control-plane", ansible_group = "k8s_controlplane_lan", zone = "lan", host = "prdxcpsrv001", ip = "192.168.10.161", cpus = 4, memory_gb = 6, disk_gb = 60 }
prdk8sgru502 = { role = "control-plane", ansible_group = "k8s_controlplane_lan", zone = "lan", host = "prdxcpsrv004", ip = "192.168.10.162", cpus = 4, memory_gb = 6, disk_gb = 60 }
prdk8sgru503 = { role = "control-plane", ansible_group = "k8s_controlplane_lan", zone = "lan", host = "prdxcpsrv005", ip = "192.168.10.163", cpus = 4, memory_gb = 6, disk_gb = 60 }
# --- Workers LAN ---
prdk8smin501 = { role = "worker", ansible_group = "k8s_workers_lan", zone = "lan", host = "prdxcpsrv002", ip = "192.168.10.171", cpus = 4, memory_gb = 6, disk_gb = 80, traefik = true }
prdk8smin502 = { role = "worker", ansible_group = "k8s_workers_lan", zone = "lan", host = "prdxcpsrv004", ip = "192.168.10.172", cpus = 4, memory_gb = 6, disk_gb = 80, traefik = true }
# --- Workers DMZ (nécessitent dmz_network_name sur le pool de l'hôte) ---
prdk8smin510 = { role = "worker", ansible_group = "k8s_workers_web", zone = "dmz", host = "prdxcpsrv003", ip = "192.168.5.171", cpus = 4, memory_gb = 6, disk_gb = 80, traefik = true }
prdk8smin511 = { role = "worker", ansible_group = "k8s_workers_web", zone = "dmz", host = "prdxcpsrv005", ip = "192.168.5.172", cpus = 4, memory_gb = 6, disk_gb = 80, traefik = true }
# --- HAProxy LAN (paire sur 2 pools) ---
prdnlbhap501 = { role = "haproxy", ansible_group = "nlb_haproxy_lan", zone = "lan", host = "prdxcpsrv001", ip = "192.168.10.185", cpus = 2, memory_gb = 2, disk_gb = 60 }
prdnlbhap502 = { role = "haproxy", ansible_group = "nlb_haproxy_lan", zone = "lan", host = "prdxcpsrv004", ip = "192.168.10.186", cpus = 2, memory_gb = 2, disk_gb = 60 }
# --- HAProxy DMZ (paire sur 2 pools) ---
prdnlbhap510 = { role = "haproxy", ansible_group = "nlb_haproxy_web", zone = "dmz", host = "prdxcpsrv002", ip = "192.168.5.185", cpus = 2, memory_gb = 2, disk_gb = 60 }
prdnlbhap511 = { role = "haproxy", ansible_group = "nlb_haproxy_web", zone = "dmz", host = "prdxcpsrv005", ip = "192.168.5.186", cpus = 2, memory_gb = 2, disk_gb = 60 }
}
terraform.tfvars.example
# ---------------------------------------------------------------------------
# Datasources Xen Orchestra. Le provider parle à un seul XO qui voit les 3
# pools ; on désambiguïse via pool_id (template répliqué = même name_label
# sur chaque pool). Réseaux + template = par POOL ; SR = par HÔTE (stockage local).
# ---------------------------------------------------------------------------
data "xenorchestra_pool" "this" {
for_each = var.pools
name_label = each.key
}
data "xenorchestra_template" "this" {
for_each = var.pools
name_label = coalesce(each.value.template_name, var.template_name)
pool_id = data.xenorchestra_pool.this[each.key].id
}
data "xenorchestra_network" "lan" {
for_each = var.pools
name_label = each.value.lan_network_name
pool_id = data.xenorchestra_pool.this[each.key].id
}
data "xenorchestra_network" "dmz" {
for_each = { for k, v in var.pools : k => v if v.dmz_network_name != "" }
name_label = each.value.dmz_network_name
pool_id = data.xenorchestra_pool.this[each.key].id
}
data "xenorchestra_sr" "this" {
for_each = var.hosts
name_label = each.value.sr_name
pool_id = data.xenorchestra_pool.this[each.value.pool].id
}
# Hôtes XCP-ng réellement utilisés par des nœuds → ID pour figer le placement.
data "xenorchestra_host" "this" {
for_each = local.used_hosts
name_label = each.value
}
# ---------------------------------------------------------------------------
# Déploiement des VMs (un module générique, piloté par la map var.nodes)
# ---------------------------------------------------------------------------
module "node" {
source = "./modules/k8s-node"
for_each = var.nodes
hostname = each.key
template_id = data.xenorchestra_template.this[var.hosts[each.value.host].pool].id
sr_id = data.xenorchestra_sr.this[each.value.host].id
network_id = each.value.zone == "dmz" ? data.xenorchestra_network.dmz[var.hosts[each.value.host].pool].id : data.xenorchestra_network.lan[var.hosts[each.value.host].pool].id
cpus = each.value.cpus
memory_gb = each.value.memory_gb
disk_gb = each.value.disk_gb
tags = local.node_tags[each.key]
interface = var.network_interface
ip = each.value.ip
prefix = var.zones[each.value.zone].prefix
gateway = var.zones[each.value.zone].gateway
dns_servers = var.dns_servers
search_domain = var.search_domain
affinity_host_id = data.xenorchestra_host.this[each.value.host].id
}
main.tf
locals {
# Hôtes XCP-ng distincts réellement utilisés par des nœuds.
used_hosts = toset([for n in var.nodes : n.host])
# ---- Construction de l'inventaire Ansible -------------------------------
# Les hôtes ne sont déclarés QUE dans les groupes de rôle ; les groupes
# transverses (clu_k8s_xkub, os_lin_r10, sys_net_lan, sys_net_web) les
# référencent comme enfants. Avantage : le baremetal prdk8smin520, ajouté
# à la main dans k8s_workers_lan + gpu, héritera automatiquement de tous
# les groupes transverses.
role_groups = [
"k8s_controlplane_lan",
"k8s_workers_lan",
"k8s_workers_web",
"nlb_haproxy_lan",
"nlb_haproxy_web",
]
# Répartition zone réseau -> groupes de rôle (gpu = baremetal, côté LAN).
# La cohérence zone/groupe des nœuds est garantie par une validation
# de var.nodes.
lan_groups = ["k8s_controlplane_lan", "k8s_workers_lan", "nlb_haproxy_lan", "gpu"]
web_groups = ["k8s_workers_web", "nlb_haproxy_web"]
group_hosts = {
for g in local.role_groups : g => {
for name, n in var.nodes :
"${name}.${var.search_domain}" => { ansible_host = n.ip }
if n.ansible_group == g
}
}
# Tags XO par nœud : miroir exact des groupes d'inventaire Ansible de la VM
# (rôle + transverses + traefik éventuel). Exploitable dans XO pour la
# recherche, les smart backups et les ACLs.
node_tags = {
for name, n in var.nodes : name => concat(
[
n.ansible_group,
"clu_k8s_xkub",
"os_lin_r10",
n.zone == "dmz" ? "sys_net_web" : "sys_net_lan",
],
n.traefik ? [n.zone == "dmz" ? "k8s_traefik_web" : "k8s_traefik_lan"] : [],
)
}
# Groupes applicatifs Traefik : sous-ensemble des workers portant le flag
# traefik dans var.nodes (une instance LAN, une instance WEB). Seuls groupes
# à redéclarer des hôtes en plus des groupes de rôle — c'est voulu : le
# périmètre Traefik est un choix par nœud, pas déductible d'un groupe.
traefik_hosts = {
for z, g in { lan = "k8s_traefik_lan", dmz = "k8s_traefik_web" } : g => {
for name, n in var.nodes :
"${name}.${var.search_domain}" => { ansible_host = n.ip }
if n.traefik && n.zone == z
}
}
inventory = {
all = {
vars = {
ansible_user = "ansible-windows"
}
children = {
# Groupe cluster : toutes les machines de la plateforme xkub.
"clu_k8s_xkub" = {
children = merge(
{ for g in local.role_groups : g => { hosts = local.group_hosts[g] } },
{ gpu = { hosts = {} } },
)
}
# Groupes applicatifs Traefik (hôtes déclarés directement, cf. plus haut).
"k8s_traefik_lan" = { hosts = local.traefik_hosts.k8s_traefik_lan }
"k8s_traefik_web" = { hosts = local.traefik_hosts.k8s_traefik_web }
# Groupes transverses : références (vides) vers les groupes de rôle.
"os_lin_r10" = { children = { for g in concat(local.role_groups, ["gpu"]) : g => {} } }
"sys_net_lan" = { children = { for g in local.lan_groups : g => {} } }
"sys_net_web" = { children = { for g in local.web_groups : g => {} } }
}
}
}
}
locals.tf
# Génère l'inventaire Ansible consommé en phase 3.
# Méthode : yamlencode() (YAML toujours valide) plutôt qu'un templatefile,
# pour garantir que `ansible-inventory --graph` parse le fichier sans surprise
# de mise en forme. Le baremetal prdk8smin520 reste à ajouter à la main.
resource "local_file" "ansible_inventory" {
filename = "${path.module}/../xkub.coolcorp.priv.ansible/inventory/hosts.yml"
# local_file crée en 0777 par défaut : un inventaire n'a pas à être exécutable.
file_permission = "0644"
content = <<-EOT
# ------------------------------------------------------------------
# Inventaire Ansible GÉNÉRÉ par Terraform (phase 2) — NE PAS éditer.
# Régénéré à chaque `terraform apply`.
# Baremetal prdk8smin520 (GPU) : à ajouter MANUELLEMENT aux groupes
# k8s_workers_lan ET gpu après la phase 11 — il héritera alors
# automatiquement des groupes transverses clu_k8s_xkub, os_lin_r10
# et sys_net_lan.
# ------------------------------------------------------------------
${yamlencode(local.inventory)}
EOT
}
output "deployed_nodes" {
description = "Récapitulatif des VMs déclarées (hostname => IP)."
value = { for name, n in var.nodes : name => n.ip }
}
output "vm_ids" {
description = "IDs Xen Orchestra des VMs créées."
value = { for k, m in module.node : k => m.vm_id }
}
outputs.tf
resource "xenorchestra_vm" "this" {
name_label = var.hostname
name_description = "xkub.coolcorp.priv — ${var.hostname}"
template = var.template_id
cpus = var.cpus
memory_max = var.memory_gb * 1024 * 1024 * 1024
# Tags visibles/filtrables dans XO (recherche, smart backup, ACLs).
tags = var.tags
# Hôte préféré pour l'anti-affinité (soft, cf. limite documentée).
affinity_host = var.affinity_host_id
cloud_config = templatefile("${path.module}/../../templates/cloud-init/user-data.tftpl", {
hostname = var.hostname
fqdn = "${var.hostname}.${var.search_domain}"
})
cloud_network_config = templatefile("${path.module}/../../templates/cloud-init/network-config.tftpl", {
interface = var.interface
ip = var.ip
prefix = var.prefix
gateway = var.gateway
dns_servers = var.dns_servers
search_domain = var.search_domain
})
network {
network_id = var.network_id
}
disk {
sr_id = var.sr_id
name_label = "${var.hostname}-root"
size = var.disk_gb * 1024 * 1024 * 1024
}
# L'IP étant statique (cloud-init), l'inventaire est généré à partir de la
# valeur déclarée, sans attendre la découverte par les guest tools. Pour
# forcer Terraform à bloquer jusqu'à l'IP, ajouter wait_for_ip = true
# (vérifier le support dans la version exacte du provider avant usage).
}
modules/k8s-node/main.tf
variable "hostname" {
description = "Hostname court de la VM (= name_label XO)."
type = string
}
variable "template_id" {
description = "ID du template XO à cloner."
type = string
}
variable "sr_id" {
description = "ID du Storage Repository pour le disque."
type = string
}
variable "network_id" {
description = "ID du réseau XO à attacher."
type = string
}
variable "cpus" {
type = number
}
variable "memory_gb" {
type = number
}
variable "disk_gb" {
type = number
}
variable "interface" {
description = "Nom de l'interface réseau côté cloud-init."
type = string
}
variable "ip" {
description = "IP statique (sans préfixe)."
type = string
}
variable "prefix" {
description = "Préfixe CIDR du sous-réseau."
type = number
}
variable "gateway" {
type = string
}
variable "dns_servers" {
type = list(string)
}
variable "search_domain" {
type = string
}
variable "affinity_host_id" {
description = "ID de l'hôte XCP-ng préféré (anti-affinité), ou null."
type = string
default = null
}
variable "tags" {
description = "Tags XO/XAPI posés sur la VM (miroir des groupes d'inventaire Ansible)."
type = set(string)
default = []
}
modules/k8s-node/variables.tf
output "vm_id" {
description = "ID XO de la VM créée."
value = xenorchestra_vm.this.id
}
output "ip" {
description = "IP statique de la VM."
value = var.ip
}
modules/k8s-node/outputs.tf
terraform {
required_providers {
xenorchestra = {
source = "vatesfr/xenorchestra"
}
}
}
modules/k8s-node/versions.tf
#cloud-config
# Hostname / FQDN appliqués au clone. La clé SSH ansible-windows est déjà
# présente dans le template Packer : on ne la redéploie pas ici.
fqdn: ${fqdn}
hostname: ${hostname}
prefer_fqdn_over_hostname: true
manage_etc_hosts: true
templates/cloud-init/user-data.tftpl
version: 1
config:
- type: physical
name: ${interface}
subnets:
- type: static
address: ${ip}/${prefix}
gateway: ${gateway}
- type: nameserver
address: [${join(", ", formatlist("%q", dns_servers))}]
search: [${search_domain}]
templates/cloud-init/network-config.tftpl
L’IA, grâce à un subagent spécifique déployé et exploité par Claude Code, comme décrit en introduction de ce cookbook, a bien optimisé la logique.
Un seul bloc « node » dans main.tf suffit à traiter tous les nodes. Grâce à l’instruction « for_each », chaque VM déclarée dans terraform.tfvars est traitée. En plus l’usage de outputs.tf permet de générer l’inventaire Ansible qu’on pourra exploiter si nécessaire par la suite lors de la prochaine étape.
D’ailleurs les noms des groupes utilisés pour générer le futur inventaire Ansible servent également comme tag pour chaque VM au niveau de XCP-ng. C’est une manière simple de pouvoir par la suite scripter bon nombre de traitements automatisés pour divers usages en filtrant sur ces labels.
Pour poursuivre, on va remplir le fichier de variables pour y inscrire le détail des VMs à déployer et surtout leur emplacement dans les pools XCP-ng.
On commence donc par copier le fichier exemple :
Copy-Item terraform.tfvars.example terraform.tfvars
Cliquez sur l'image pour l'agrandir.
On l’édite, mais pour savoir quelles valeurs remplir, il va falloir se déplacer dans l’interface de Xen Orchestra (GUI v5 ou v6). Le but est de récupérer les noms des réseaux, en l’occurrence LAN et VLAN_WEB, pour mon lab sous XCP-ng.
Cliquez sur l'image pour l'agrandir.
Attention : ces noms sont définis au niveau de chaque pool XCP. Dans mon cas, j’ai utilisé les mêmes dénominations sur les trois pools. Ensuite, il faut récupérer les SR (storage repository) qu’on va souhaiter utiliser pour stocker les VMs.
Cliquez sur l'image pour l'agrandir.
Je vais privilégier les SR locaux aux serveurs, sauf pour quelques VMs que je vais héberger sur mon SR répliqué XOSTOR. Pour ceux à qui ça ne parle pas, n’hésitez pas à passer par mon article sur le sujet et le stockage sur XCP-ng.
Mon but est d’avoir une répartition des rôles entre les nœuds physiques. Les control plane doivent fonctionner sur des serveurs distincts, de même que les workers fournissant des services pour la même zone réseau. C’est également la contrainte à respecter pour les VMs HAproxy.
L’objectif est d’éviter les SPOF (Single Point Of Failure). La perte d’un serveur physique entrainerait alors celle du cluster K8S.
Voici le fichier terraform.tfvars final :
pools = {
"xcp-pool" = { lan_network_name = "LAN", dmz_network_name = "VLAN_WEB" }
"prdxcpsrv004" = { lan_network_name = "LAN", dmz_network_name = "VLAN_WEB" }
"prdxcpsrv005" = { lan_network_name = "LAN", dmz_network_name = "VLAN_WEB" }
}
hosts = {
prdxcpsrv001 = { pool = "xcp-pool", sr_name = "default-local-prdxcpsrv001" }
prdxcpsrv002 = { pool = "xcp-pool", sr_name = "default-local-prdxcpsrv002" }
prdxcpsrv003 = { pool = "xcp-pool", sr_name = "XOSTOR" }
prdxcpsrv004 = { pool = "prdxcpsrv004", sr_name = "samsung-local-prdxcpsrv004" }
prdxcpsrv005 = { pool = "prdxcpsrv005", sr_name = "samsung-local-prdxcpsrv005" }
}
nodes = {
# --- Control plane (1 par pool = HA maximale) ---
prdk8sgru501 = { role = "control-plane", ansible_group = "k8s_controlplane_lan", zone = "lan", host = "prdxcpsrv002", ip = "192.168.10.161", cpus = 4, memory_gb = 6, disk_gb = 60 }
prdk8sgru502 = { role = "control-plane", ansible_group = "k8s_controlplane_lan", zone = "lan", host = "prdxcpsrv004", ip = "192.168.10.162", cpus = 4, memory_gb = 6, disk_gb = 60 }
prdk8sgru503 = { role = "control-plane", ansible_group = "k8s_controlplane_lan", zone = "lan", host = "prdxcpsrv005", ip = "192.168.10.163", cpus = 4, memory_gb = 6, disk_gb = 60 }
# --- Workers LAN ---
prdk8smin501 = { role = "worker", ansible_group = "k8s_workers_lan", zone = "lan", host = "prdxcpsrv004", ip = "192.168.10.171", cpus = 4, memory_gb = 6, disk_gb = 80, traefik = true }
prdk8smin502 = { role = "worker", ansible_group = "k8s_workers_lan", zone = "lan", host = "prdxcpsrv005", ip = "192.168.10.172", cpus = 4, memory_gb = 6, disk_gb = 80, traefik = true }
# --- Workers DMZ (nécessitent dmz_network_name sur le pool de l'hôte) ---
# srv004 (SR samsung 1 TB) : le SR default-local de srv003 (161 GB) était trop juste pour hap510 + min510
prdk8smin510 = { role = "worker", ansible_group = "k8s_workers_web", zone = "dmz", host = "prdxcpsrv004", ip = "192.168.5.171", cpus = 4, memory_gb = 6, disk_gb = 80, traefik = true }
prdk8smin511 = { role = "worker", ansible_group = "k8s_workers_web", zone = "dmz", host = "prdxcpsrv005", ip = "192.168.5.172", cpus = 4, memory_gb = 6, disk_gb = 80, traefik = true }
# --- HAProxy LAN (paire sur 2 pools) ---
# srv002 : le SR default-local de srv001 (43 GB libres) héberge aussi le disque de XO, pas de place pour hap501
prdnlbhap501 = { role = "haproxy", ansible_group = "nlb_haproxy_lan", zone = "lan", host = "prdxcpsrv003", ip = "192.168.10.185", cpus = 2, memory_gb = 2, disk_gb = 60 }
prdnlbhap502 = { role = "haproxy", ansible_group = "nlb_haproxy_lan", zone = "lan", host = "prdxcpsrv004", ip = "192.168.10.186", cpus = 2, memory_gb = 2, disk_gb = 60 }
# --- HAProxy DMZ (paire sur 2 pools) ---
prdnlbhap510 = { role = "haproxy", ansible_group = "nlb_haproxy_web", zone = "dmz", host = "prdxcpsrv003", ip = "192.168.5.185", cpus = 2, memory_gb = 2, disk_gb = 60 }
prdnlbhap511 = { role = "haproxy", ansible_group = "nlb_haproxy_web", zone = "dmz", host = "prdxcpsrv005", ip = "192.168.5.186", cpus = 2, memory_gb = 2, disk_gb = 60 }
}
terraform.tfvars
Le déploiement de l’ensemble se fait très facilement via quelques commandes.
Mais avant, il est nécessaire de récupérer les informations d’authentification pour permettre à Terraform d’agir sur ma plateforme Xen Orchestra.
Pour cela, je vais commencer par ajouter un utilisateur local à XO, nommé iac. C’est simplement pour pouvoir isoler les actions faites par Terraform du reste. D’un point de vue sécurité, c’est également un plus, puisqu’on peut si besoin, limiter ce compte au strict nécessaire. Pour ma part, j’y vais large en lui donnant le droit d’admin… dans mon cas, c’est plutôt un confort, mais, en entreprise, il vaut mieux éviter de donner ce type de rôle par défaut à un compte de service. Préférez définir un rôle spécifique en prenant soin de lui associer les droits nécessaires.
Cliquez sur l'image pour l'agrandir.
Par la suite, en prenant l’identité de ce nouveau compte iac, je peux générer un token de connexion.
Cliquez sur l'image pour l'agrandir.
Cliquez sur l'image pour l'agrandir.
Bien entendu ce token devient critique et sa gestion est très sensible. XO vous permet de limiter la durée de vie du token. Évitez donc les tokens avec cinq ans d’âge…
Cliquez sur l'image pour l'agrandir.
Ici aussi, en environnement professionnel, il serait préférable d’utiliser un outil comme Vault pour mettre en place une mécanique de coffre-fort et de gestion automatisée du token. En plus, ça tombe bien, Vault est aussi dans le catalogue de HashiCorp et s’intègre très bien avec Terraform. Je ne vais pas entrer dans ce niveau de configuration ici, mais, si le sujet vous intéresse, je vous invite à lire mon article sur Vault dans le cadre de la mise en œuvre d’un agent IA sous OpenClaw.
Une fois le token en votre possession, comme on reste basique ici, il va simplement falloir le déclarer en tant que variable dans votre shell. Ce n’est peut-être pas parfait, mais au moins, il sera invisible dans la configuration et disparaîtra lorsque vous aurez terminé votre déploiement et quitté votre session shell.
Par exemple, pour moi sous powershell :
$env:XOA_TOKEN = "<token XO>"
Dans la même lignée, on déclare une variable pour l’URL de Xen Orchestra :
$env:XOA_URL = "wss://prdxcpxor501.coolcorp.priv:8443"
Cliquez sur l'image pour l'agrandir.
Cliquez sur l'image pour l'agrandir.
Comme pour Packer, on initialise le projet, notamment pour récupérer le provider Terraform de Vates :
terraform init
Cliquez sur l'image pour l'agrandir.
Puis on valide la syntaxe et la présence de toutes les variables :
terraform fmt -recursive
terraform validate
Cliquez sur l'image pour l'agrandir.
On génère une fois le plan, ce qui nous permet de vérifier ce qui va être produit :
terraform plan
Cliquez sur l'image pour l'agrandir.
Puis, si la sortie nous parait conforme, on peut passer au déploiement via la commande :
terraform apply -parallelism=3
Cliquez sur l'image pour l'agrandir.
L’option « parallelism » est importante, car sans cela, Terraform va essayer de provisionner toutes les VMs en parallèle, ce qui n’est pas supporté par le provider de Vates par défaut pour des questions de sécurité et de performance.
En limitant le nombre de déploiements parallèles à trois on évite des erreurs.
La commande terraform apply vous demandera de valider par « yes » le lancement des actions, une occasion supplémentaire de bien vérifier la sortie proposée.
Cliquez sur l'image pour l'agrandir.
Si on valide, il ne devrait pas falloir plus de quelques minutes pour que l’ensemble de nos nœuds K8S démarrent et soient prêts à être configurés pour la suite.
Cliquez sur l'image pour l'agrandir.
Cliquez sur l'image pour l'agrandir.
On peut vérifier que les VMs sont bien toutes présentes et que les labels sont bien associés.
Cliquez sur l'image pour l'agrandir.
Cliquez sur l'image pour l'agrandir.
Cliquez sur l'image pour l'agrandir.
Cliquez sur l'image pour l'agrandir.
Ne reste plus qu’à faire un dernier contrôle en exploitant Ansible, l’inventaire généré et la clef SSH dédiée (voir introduction au cookbook).
Si tout va bien, et étant donné que toutes nos VMs ont été clonées à partir de l’image créée avec Packer, on devrait pouvoir lancer des commandes Ansible pour vérifier l’accès aux VMs :
ansible all -i ../xkub.coolcorp.priv.ansible/inventory/hosts.yml -m ping --private-key <chemin_clé_privée_ansible-windows> --ssh-common-args='-o StrictHostKeyChecking=accept-new'
Comme il s’agit d’une première connexion, il faut passer l’argument « StrictHostKeyChecking=accept-new » pour que côté Ansible les hosts soient validés.
On peut aller plus loin, comme utiliser le module setup de Ansible :
ansible all -i ../xkub.coolcorp.priv.ansible/inventory/hosts.yml --private-key <chemin_clé_privée_ansible-windows> -m setup -a 'filter=ansible_default_ipv4'
On rentrera plus dans le détail à ce niveau lors du prochain article.
Dernière petite config : activer le boot automatique pour toutes les VMs. On aurait très certainement pu activer cette option dans le code terraform. C’est sans doute une amélioration à faire. En attendant, je l’active manuellement pour chaque VM dans les paramètres avancés. Cela permettra qu’en cas de redémarrage d’un serveur XCP-ng, les VMs portées redémarrent automatiquement.
Cliquez sur l'image pour l'agrandir.
Toutes les ressources virtuelles propres au futur cluster Kubernetes ont été déployées, et ceci de manière fiable et précise. Encore une fois l’écosystème de Vates est pleinement compatible avec les outils de IAC comme Terraform.
Chaque nœud est correctement réparti dans mes clusters XCP-ng pour maximiser la disponibilité des services et assurer un niveau minimal de tolérance aux pannes.
L’usage de l’IA a permis de simplifier l’écriture du code HCL sans pour autant que cela ne m’empêche de contrôler ce qui est fait et de comprendre ce qui est produit.
Si cela vous intéresse, vous pouvez retrouver l’ensemble du code Terraform dans mon GitHub, ainsi que le fichier terraform-provisioner.md utilisé pour le subagent de claude code.
On va pouvoir enchainer sur Ansible et la configuration des nodes… dans un prochain article.