Aller au contenu
Mon homelab de zéro (Partie 10) : golden images, workflow Git, et quatre erreurs pour un seul téléchargement
Photo by Fernando Lavin / Unsplash
  1. Articles/

Mon homelab de zéro (Partie 10) : golden images, workflow Git, et quatre erreurs pour un seul téléchargement

·3457 mots·17 mins·
Sommaire
Mon homelab de zéro - Cet article fait partie d'une série.
Partie 10: Cet article

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 planet 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énementCe qui se passe
Push sur une branche + MR ouvertetofu plan → artefact lisible dans la MR
Merge sur maintofu 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.

note

« 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.

note

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 found

Le 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.

note

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-lease

Notez 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_command fragile : 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.

note

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.

note

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.

note

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"}
note

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.

note

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.Syslog

PVESysAdmin 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 TofuExtra
note

Le double enseignement le plus fort de la phase.

  1. 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.
  2. Ne jamais deviner le contenu d’un rôle, le lire (pveum role list). J’avais empilé PVEAdmin + PVESysAdmin en 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,import

Erreur 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 import

L’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.

note

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 Gitmain protégée, plan en MR, apply manuel, fast-forward
Template Debian 13VM 9000 sur pve01, cloud-init, agent, console série
Image Debian genericcloud328 Mo, présente sur les 3 nœuds
Image Talos v1.13.74,2 Go (avec qemu-guest-agent), sur les 3 nœuds
Token Proxmox+ rôle custom TofuExtra (Sys.Modify)
Stockage localcontenu import activé
Packerabandonné (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 :

  1. 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é.
  2. 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 !

Kentrow
Auteur
Kentrow
Partage de notes et astuces IT : réseaux, serveurs, DevOps, sécurité, homelab et plus encore.
Mon homelab de zéro - Cet article fait partie d'une série.
Partie 10: Cet article

Articles connexes