Dans la partie 9, on a consolidé les fondations : OpenTofu, un state chiffré et une CI qui tourne avec le runner dans le cluster. J’avais promis, pour cette phase, des golden images construites avec Packer. Sauf que… j’ai changé d’avis en cours de route et c’est l’un des deux fils rouges de cet article.
Cette phase fait en effet deux choses distinctes mais liées. D’abord j’en profite pour basculer le projet vers un vrai workflow de branches, celui qui rend enfin le tofu plan de la CI utile. Ensuite, elle construit les images de base dont nous aurons besoin pour les VM à venir : un template Debian 13 cloud-init et une image Talos sur mesure, disponibles sur les trois PVE.
Et comme d’habitude, ce sont les échecs qui font l’article. Cette fois-ci pas mal de soucis : quatre échecs successifs sur une seule et même ressource : un simple téléchargement d’image. Aucun tutoriel ne montre ce chemin : ils présentent tous le .tf final comme s’il était tombé du ciel. Dans la pratique, c’était bien plus compliqué que ça.
Partie 1 : le workflow Git, ou le plan qui arrivait trop tard#
Le constat qui déclenche tout#
Jusqu’ici, je poussais directement sur main, en branche unique. Pragmatique pendant le bootstrap : au début d’un projet, on veut avancer, pas perdre du temps. Mais la phase précédente a introduit un pipeline avec un tofu plan… et un plan qui s’exécute après le merge ne sert à rien.
Tout l’intérêt d’un plan, c’est de voir ce qui va changer avant de décider. Poussé sur main, il me montrait sagement ce que je venais déjà d’appliquer. Autant dire un bulletin météo pour hier.
Le workflow retenu : GitLab Flow simplifié#
La correction tient en deux règles :
| Événement | Ce qui se passe |
|---|---|
| Push sur une branche + MR ouverte | tofu plan → artefact lisible dans la MR |
Merge sur main | tofu apply en job manuel |
Avec :
main= l’état de l’infrastructure, protégée : plus aucun push direct- Une branche par changement (
feat/golden-images,fix/zfs-mountpoint) - Merge fast-forward → un historique strictement linéaire
Le plan tournant désormais sur la MR, je le lis avant de merger. Le pipeline retrouve son sens.
« Mais tu es seul sur ce dépôt, pourquoi tant de cérémonie ? » Parce que ce n’en est pas. Le workflow de branches rend le plan utile, force à relire ses propres changements et produit un historique lisible. À moi-même dans six mois, une MR « add Talos control plane » avec son plan attaché permet de ne rien perdre. Le vrai bénéficiaire d’un bon historique Git, quand on est seul, c’est soi-même plus tard.
L’apply manuel, garde-fou assumé#
Le point resté ouvert à la fin de la phase précédente, je le tranche ici : l’apply est un job manuel, pas automatique. Un merge peut détruire une VM par une simple erreur de code, le clic laisse une dernière chance de lire le plan avant de laisser OpenTofu toucher au réel.
Et la bonne surprise, c’est que côté pipeline, il n’y a presque rien à changer. Les règles écrites en partie 9 anticipaient déjà ce fonctionnement : le tofu-plan se déclenche sur merge_request_event, et le tofu-apply est déjà un job manual sur main. Ce qui manquait n’était donc pas le YAML, c’était l’usage. Tant que je poussais tout sur main, ces règles tournaient à vide (le plan s’exécutait après coup). En adoptant les branches et en protégeant main, le pipeline se met enfin à servir à ce pour quoi il avait été écrit.
Un choix de sécurité discret mais important. Le fichier tfplan binaire n’est pas passé en artefact entre les jobs plan et apply : il contiendrait des secrets en clair. Le tofu-plan n’exporte que plan.txt, une version lisible destinée à la MR. L’apply, lui, recalcule son propre plan. On perd la garantie théorique « ce qui est appliqué est exactement ce qui a été planifié », mais on gagne de ne pas trimballer un fichier sensible dans les artefacts. Compromis sécurité/rigueur assumé.
Les quatre pièges de la protection de branches#
C’est en verrouillant main que les ennuis commencent. Quatre accrocs, tous authentiques, tous instructifs.
1. La variable CI masquée refuse le multi-ligne.
failed to load age identities: no secret keys foundLe fichier keys.txt généré par age-keygen contient trois lignes : deux commentaires (# created:, # public key:) et la clé elle-même. Or les variables Masked de GitLab n’acceptent pas le multi-ligne, la valeur arrivait tronquée, voire vide. Le correctif : ne copier que la ligne de la clé.
2. Protected bloque toutes les branches de travail.
Une variable CI marquée Protected n’est disponible que sur les branches… protégées. Ma branche de MR ne l’étant pas, le job n’y avait aucun accès, donc aucune MR ne pouvait jamais faire de plan. Le workflow entier vidé de son sens. Le correctif : décocher Protected (la variable reste Masked dans les logs, ce qui protège l’essentiel).
3. Se bloquer soi-même avec un wildcard.
Tentative de contournement du piège précédent : protéger * (toutes les branches). Résultat :
! [remote rejected] feat/golden-images -> feat/golden-images (pre-receive hook declined)En protégeant * avec « Allowed to push : No one », plus aucun push n’était possible nulle part, y compris sur les branches de travail. Je m’étais enfermé dehors.
La règle à retenir sur la protection de branches. Seule la branche cible des MR doit être protégée, jamais les branches sources. Le piège classique de la mise en place : on protège trop large, et on se bloque soi-même. main protégée, le reste libre.
4. Le fast-forward exige le rebase.
Merge blocked: Fast forward merge is not possible. Please rebase.
The source branch is 1 commit behind the target branch.C’est le compromis du mode fast-forward : un historique parfaitement linéaire, en échange d’un rebase dès que main bouge sous vos pieds.
git fetch origin && git rebase origin/main && git push --force-with-leaseNotez le --force-with-lease plutôt que --force : il refuse de pousser si la branche distante a changé entre-temps. Un réflexe à garder même quand on est seul : c’est le filet qui évite d’écraser un commit qu’on aurait oublié.
Alternative si le rebase systématique devient pénible : le mode « Merge commit with semi-linear history », qui exige aussi le rebase mais garde une trace explicite de chaque MR. Pour l’instant, le linéaire strict me convient.
Partie 2 : les golden images#
Décision : j’abandonne Packer#
Voilà le moment où je reviens sur ma promesse de la partie 9. Pour construire le template Debian, deux philosophies s’affrontaient :
Option A, Packer + ISO : Packer démarre une VM sur l’ISO netinst, pilote l’installeur via un preseed servi en HTTP, installe cloud-init et l’agent, puis convertit le tout en template.
- Avantages : Contrôle total sur ce qui est « cuit » dans l’image et très formateur
- Inconvénients : 15 à 20 minutes par build et surtout un
boot_commandfragile : Packer tape littéralement au clavier dans la console VNC pour amorcer le preseed. C’est le point qui casse le plus souvent
Option B, image cloud officielle + OpenTofu : Debian publie des images genericcloud en qcow2, déjà prêtes pour cloud-init. Le provider bpg les télécharge et en fait un template sans Packer du tout.
- Avantages : Quelques minutes, image officielle, une brique de moins à maintenir
- Inconvénients : Moins de contrôle sur ce qui est préinstallé
J’ai choisi l’option B.
Pourquoi ce choix et ce changement de plan ? Ansible est déjà en place pour configurer les VM après leur création (c’est tout le sujet des phases précédentes). Inclure des paquets dans l’image (avec Packer) n’apporte donc presque rien et me ferait maintenir un pipeline Packer fragile pour un gain marginal. Le pattern moderne, c’est image officielle minimale + cloud-init + configuration par Ansible.
Le contre-argument que j’assume : Packer est un outil utilisé en contexte professionnel et le critère n°1 de ce projet reste l’apprentissage. Je ne l’abandonne donc pas pour toujours : il fera peut-être l’objet d’un autre article dédié plus tard.
Décision au passage : Dokploy en VM, pas en LXC#
Question posée pour la phase suivante, mais tranchée ici parce qu’elle détermine le type d’image dont j’ai besoin : la future VM applicative (Dokploy) sera une VM, pas un conteneur LXC.
La raison est précise : Dokploy s’appuie sur Docker Swarm, pas seulement sur Docker. Swarm manipule des réseaux overlay, iptables, des modules noyau : autant de choses qui fonctionnent mal ou pas du tout dans un conteneur non privilégié. Même avec nesting=true, on passerait son temps à contourner.
Une règle générale qui se dégage. LXC pour les services simples (DNS, runner CI, reverse proxy), VM dès qu’il y a une couche de virtualisation ou d’orchestration à l’intérieur (Docker/Swarm, Kubernetes). Le surcoût d’une VM est réel mais incontournable pour certains usages et il permet un isolement complet natif.
Le template Debian 13#
Concrètement, deux ressources OpenTofu : le téléchargement de l’image cloud, puis la VM template qui s’appuie dessus.
# tofu/stacks/core/images.tf (portion Debian)
locals {
debian_image_url = "https://cloud.debian.org/images/cloud/trixie/${var.debian_image_snapshot}/debian-13-genericcloud-amd64-${var.debian_image_snapshot}.qcow2"
}
resource "proxmox_virtual_environment_download_file" "debian_cloud" {
for_each = toset(var.cluster_nodes)
node_name = each.value
content_type = "import"
datastore_id = "local"
file_name = "debian-13-genericcloud-amd64-${var.debian_image_snapshot}.qcow2"
url = local.debian_image_url
checksum = var.debian_image_checksum
checksum_algorithm = "sha512"
overwrite = false
}
resource "proxmox_virtual_environment_vm" "debian_template" {
node_name = var.runner_node
vm_id = var.template_vm_id
name = "debian-13-cloudinit"
template = true
started = false
agent { enabled = true }
cpu {
cores = 2
type = "host" # expose le CPU hôte
}
memory { dedicated = 2048 }
disk {
datastore_id = var.storage_pool
interface = "scsi0"
import_from = proxmox_virtual_environment_download_file.debian_cloud[var.runner_node].id
size = 20
discard = "on"
ssd = true
}
network_device { bridge = "vmbr0" }
# Les clones surchargeront, on laisse DHCP sur le template
initialization {
datastore_id = var.storage_pool
ip_config { ipv4 { address = "dhcp" } }
user_account {
username = "debian"
keys = [trimspace(var.ssh_public_key)]
}
}
operating_system { type = "l26" }
serial_device {}
lifecycle {
ignore_changes = [disk[0].file_id]
}
}Pourquoi le checksum est essentiel. L’URL pointe déjà vers un snapshot daté (var.debian_image_snapshot) plutôt que vers latest/, mais ça ne suffit pas : à URL constante, Debian pourrait republier. Le checksum SHA512 verrouille le contenu exact. Sans lui, OpenTofu pourrait récupérer une image différente sans broncher. Avec, la moindre divergence d’octet fait échouer le téléchargement.
latest est l’ennemi du déterminisme. C’est le fil transverse de toute cette phase : le snapshot Debian figé dans l’URL et verrouillé par checksum SHA512 ici, le schematic id déterministe de Talos plus bas, la version Talos épinglée en variable. Trois façons de refuser une cible mouvante dans une infra qui se veut reproductible.
Les autres choix méritent un mot :
import_from: le disque du template est directement importé depuis l’image cloud téléchargée sur le même nœud (debian_cloud[var.runner_node].id). Pas de conversion manuelle, OpenTofu enchaîne téléchargement → import.serial_device {}+operating_system { type = "l26" }: les images cloud Debian attendent une console série et un profil noyau Linux 2.6+. Sans la console série, pas de sortie utilisable dans Proxmox.cpu type = "host": expose les instructions du processeur hôte. Indispensable pour les performances et pour Talos/Kubernetes plus tard.discard = "on"+ssd = true: le TRIM est propagé jusqu’au pool ZFS : l’espace libéré dans la VM est réellement rendu au stockage.lifecycle { ignore_changes = [disk[0].file_id] }: évite une recréation intempestive du template si l’identifiant du fichier source change.
L’image Talos#
Talos ne se télécharge pas « brut » : on passe par l’Image Factory de Sidero, qui produit une image sur mesure avec les extensions demandées. Ici, qemu-guest-agent, sans lequel Proxmox ne connaît ni l’IP ni l’état réel de la VM.
curl -sX POST --data-binary @- https://factory.talos.dev/schematics << 'EOF'
customization:
systemExtensions:
officialExtensions:
- siderolabs/qemu-guest-agent
EOF
# -> {"id":"53513e54bb39202f35694412577a6bc53d484744d35a126e5d42ef34785c0d83"}Le schematic id est un hash déterministe de la configuration. La même demande produit toujours le même identifiant. Parfait pour la reproductibilité : on le fige en variable OpenTofu, et on est certain de reconstruire exactement la même image.
Côté OpenTofu, la ressource construit l’URL de l’Image Factory à partir du schematic id et de la version, et télécharge l’image sur chaque nœud :
# tofu/stacks/core/images.tf (portion Talos)
locals {
talos_image_url = join("/", [
"https://factory.talos.dev/image",
var.talos_schematic_id,
var.talos_version,
"nocloud-amd64.raw",
])
}
resource "proxmox_virtual_environment_download_file" "talos" {
for_each = toset(var.cluster_nodes)
node_name = each.value
content_type = "import"
datastore_id = "local"
# Le nom porte le schematic id : deux variantes peuvent coexister pendant une bascule
file_name = "talos-${var.talos_version}-${substr(var.talos_schematic_id, 0, 8)}-nocloud-amd64.raw"
url = local.talos_image_url
overwrite = false
upload_timeout = 3600 # 4,2 Go à télécharger
lifecycle {
create_before_destroy = true
}
}Deux détails qui piègent :
- Il faut prendre la variante
nocloud-amd64: c’est celle qui intègre le support nocloud dont le provider a besoin pour injecter la configuration machine sous Proxmox. - Pas de template VM pour Talos, contrairement à Debian. Les VM Talos seront créées directement depuis l’image, avec leur configuration machine injectée. C’est le mode de fonctionnement attendu par Talos, qui n’a pas de notion de personnalisation post-clonage : chaque nœud reçoit sa config, point.
Les images sur les trois nœuds (for_each)#
Le problème est un vieux copain de cette série : le stockage local n’est pas partagé entre nœuds. Mes images n’existaient que sur pve01, or la prochaine phase voudra une VM Talos par nœud physique.
Trois options :
- A. Télécharger sur les trois nœuds via
for_each = toset(var.cluster_nodes). Redondant en espace (~13 Go au total), mais négligeable sur 91 Go par nœud. - B. Créer sur pve01 puis migrer : ajoute une étape, et c’est contraire au déclaratif.
- C. Stockage partagé : propre, mais je n’ai pas de NAS, et ça ajoute une dépendance. J’y réfléchis quand même sérieusement pour une évolution cohérente du homelab.
J’ai choisi l’option A. La plus simple, la plus déclarative, pour un coût dérisoire.
Un détail de state à connaître. Passer d’une ressource simple à un for_each change son adresse dans le state (.debian_cloud → .debian_cloud["pve01"]). OpenTofu propose donc de détruire puis recréer la ressource, soit ici 4,5 Go de retéléchargement pour rien. La parade : tofu state mv avant l’apply, pour lui apprendre la nouvelle adresse sans toucher au réel.
tofu state mv 'proxmox_virtual_environment_download_file.talos' \
'proxmox_virtual_environment_download_file.talos["pve01"]'Pour boucler le fil rouge du déterminisme, voici les cibles réellement figées pour cette phase :
# variables.tf + terraform.tfvars (valeurs épinglées)
debian_image_snapshot = "20260810-2566" # un snapshot Debian daté, pas "latest/"
debian_image_checksum = "0ce1f1d6...93dc" # SHA512 du qcow2 ci-dessus
talos_version = "v1.13.7"
talos_schematic_id = "53513e54...0d83" # hash déterministe (qemu-guest-agent)Les quatre erreurs d’un seul téléchargement (le cœur technique)#
Voici la partie promise en intro. Quatre échecs successifs sur une seule ressource : le téléchargement d’image. Frustrant à vivre, mais c’est exactement ce que les tutoriels classiques passent sous silence.
Erreur 1 : PVEAdmin ne suffit pas (HTTP 403)#
HTTP 403 - Reason: Permission check failed
error retrieving URL metadata for "https://cloud.debian.org/..."Enquête : pveum user permissions tofu@pve montre Sys.Audit, Sys.Console, Sys.Syslog… mais pas Sys.Modify, précisément le privilège requis pour télécharger depuis une URL.
Ça pique un peu, parce qu’en partie 9, j’avais justement ajouté PVESysAdmin au token en pensant qu’il apportait Sys.Modify pour ce moment précis. Grosse erreur : je n’avais pas vérifié le contenu réel du rôle. L’inspection le confirme :
PVESysAdmin │ Sys.Audit,Sys.Console,Sys.SyslogPVESysAdmin ne donne que des droits de lecture, malgré son nom. Il n’a jamais apporté Sys.Modify : j’avais empilé un rôle inutile. Le correctif, c’est un rôle sur mesure :
pveum role add TofuExtra --privs 'Sys.Modify'
pveum acl modify / --users tofu@pve --roles TofuExtraLe double enseignement le plus fort de la phase.
- Les rôles prédéfinis de Proxmox sont découpés par domaine, pas par niveau de pouvoir.
PVEAdmin= « administrateur des VM et du stockage », pas « administrateur tout court ». Les noms induisent en erreur. - Ne jamais deviner le contenu d’un rôle, le lire (
pveum role list). J’avais empiléPVEAdmin+PVESysAdminen supposant leur contenu, pour rien.
L’ironie, que je souligne volontiers : dans la phase initiale, j’avais choisi l’option « rôle prédéfini » précisément pour éviter les erreurs 403. J’en ai quand même eu une. Conclusion : quel que soit le choix initial, les permissions se découvrent à l’usage.
Erreur 2 : iso vs import#
scsi0: local:iso/...img has wrong type 'iso' - needs to be 'images' or 'import'J’avais passé le content_type de import à iso, « par prudence ». Mauvaise idée : Proxmox distingue strictement ces catégories et un fichier typé iso est traité comme une image de CD, pas comme un disque. Retour à import, et activation de ce type de contenu sur le stockage (il ne l’est pas par défaut) :
pvesm set local --content iso,vztmpl,backup,importErreur 3 : l’extension refusée#
HTTP 400 - (filename: invalid filename or wrong extension)Le type import n’accepte que des extensions de formats de disque (.qcow2, .raw, .vmdk) pas .img. Le .img venait d’une mauvaise habitude : beaucoup d’exemples renomment ainsi parce que l’ancien type iso refusait .qcow2. Avec import, c’est exactement l’inverse. Correctif : garder l’extension réelle du format (.qcow2).
Erreur 4 : pas de décompression pour import#
TASK ERROR: decompression not supported for importL’image Talos est servie en .raw.xz. Le provider bpg expose bien un decompression_algorithm mais Proxmox ne décompresse que pour les types iso et vztmpl, jamais pour import. Correctif : télécharger l’image déjà décompressée : l’Image Factory sert les deux variantes, il suffit de retirer le .xz de l’URL.
Le coût : 4,2 Go au lieu de beaucoup moins en version compressée, ×3 nœuds ≈ 13 Go. Acceptable et ça garde tout dans OpenTofu plutôt que d’ajouter une étape Ansible de décompression.
Une note transverse sur les ressources dépréciées. Le provider bpg renomme ses ressources (proxmox_virtual_environment_* → proxmox_*) d’ici sa v1.0. J’ai volontairement reporté la migration : elle est partielle (pas encore de proxmox_container, seulement proxmox_vm, on mélangerait deux conventions), et proxmox_vm n’est pas un simple renommage mais une réécriture sur un nouveau framework, au schéma sensiblement différent.
L’inspection du schéma a tout de même révélé trois ressources utiles pour plus tard : proxmox_replication (les jobs pvesr pilotables en OpenTofu, ça règle proprement le point laissé ouvert précédemment), proxmox_storage_zfspool et proxmox_user_token (deux choses faites en Ansible qui auraient pu être en OpenTofu, mais il n’existait pas encore dans le projet à l’époque), et proxmox_acme_* (utile plus tard pour Let’s Encrypt via l’API OVH). La frontière entre Ansible et OpenTofu est mouvante et c’est un sujet à part entière.
L’état des lieux après cette phase#
| Élément | État |
|---|---|
| Workflow Git | main protégée, plan en MR, apply manuel, fast-forward |
| Template Debian 13 | VM 9000 sur pve01, cloud-init, agent, console série |
| Image Debian genericcloud | 328 Mo, présente sur les 3 nœuds |
| Image Talos v1.13.7 | 4,2 Go (avec qemu-guest-agent), sur les 3 nœuds |
| Token Proxmox | + rôle custom TofuExtra (Sys.Modify) |
Stockage local | contenu import activé |
| Packer | abandonné (du moins pour l’instant) |
On a désormais de quoi fabriquer proprement les VM des phases suivantes : une image officielle minimale, cloud-init pour la personnalisation et Ansible derrière pour la configuration. Et un workflow Git qui fait enfin ce pour quoi il a été conçu : montrer les changements avant qu’ils ne touchent au réel.
Un point soulevé en cours de route#
En chemin, une question s’est imposée : « si pve01 crash, le runner CI redémarre-t-il ailleurs ? » La réponse est non, et pour deux raisons cumulées :
- Le HA n’est pas configuré : Proxmox ne redémarre une ressource ailleurs que si elle est déclarée dans le gestionnaire de haute disponibilité.
- Même avec le HA, ça ne suffirait pas : le disque du conteneur vit dans le pool ZFS local de pve01. Les autres nœuds ont un pool du même nom, mais vide. Il faut d’abord que les données y soient c’est le rôle de la réplication ZFS.
Il manque donc deux briques, dans cet ordre : un job pvesr (réplication) → une ressource HA. Avec ça, en cas de perte de pve01, le conteneur redémarrerait sur pve02 depuis le dernier snapshot répliqué.
Et maintenant ?#
Pour un runner CI, l’enjeu de résilience est faible (pas de données, des pipelines qui peuvent attendre). Mais c’est le cas d’école parfait pour valider le mécanisme avant de l’appliquer à des charges qui, elles, comptent.
La partie 11 attaquera donc la résilience : réplication ZFS, ressource HA, et surtout un test de bascule réel on éteint pve01 pour de vrai et on regarde si le service repart ailleurs. Et dans la foulée, on déploiera Dokploy, le premier vrai service applicatif du lab, avant que la partie 12 ne s’attaque à Talos et Kubernetes.
À bientôt !




