Aller au contenu principal

Dérive de configuration

Deux questions se posent en permanence dans ce homelab, et jusqu'au 2026-09-03 aucune des deux n'avait de réponse mécanique :

  1. Ce que le dépôt déclare est-il ce qui tourne ? Les scripts versionnés dans homelab-config sont recopiés vers /usr/local/bin/ et /root/ sur les trois hôtes. Rien ne garantissait que les copies suivaient.
  2. Les conteneurs du cluster sont-ils tels qu'on les croit ? Les dix LXC étaient montés à la main, sans description nulle part.

Elles ont désormais chacune une commande. Cette page dit laquelle, ce qu'elle couvre, et surtout ce qu'elle ne couvre pas.

Pourquoi ça compte : trois pannes muettes​

Ce ne sont pas des hypothèses, ce sont les incidents qui ont motivé le chantier.

La sonde SMART morte 114 runs. Un script corrigé dans le dépôt, jamais redéployé vers sa copie live. Le contrôle rendait vert parce qu'il ne tournait pas — voir PATH cron.

Huit jours de fuite après un correctif annoncé. Le lynis-weekly.sh de lancelot a été corrigé le 25/08 pour couper une publication vers le ntfy public. galahad avait le même cron et n'a pas été touché : il a continué à publier son indice de durcissement et son nom d'hôte sur un topic public jusqu'au 03/09. Le correctif était appliqué à un hôte, pas au parc. Récit complet : journal du 2026-09-04.

Quatre garde-fous endormis par un renommage. En renommant logs/ en logs/logs-prod-1/ et adguard/ en adguard/adguard-prod-1/, quatre contrôles ont cessé de contrôler sans cesser de rendre vert :

  • repo-drift-check.sh cherchait ses fichiers dans un répertoire devenu parent — son find ne trouvait rien, son 2>/dev/null avalait l'erreur ;
  • ci.yml et pre-merge-check.sh testaient un chemin mort par un if [ -f ] sans branche d'échec, sautant la validation en silence ;
  • un motif .gitignore ne matchait plus, laissant les fichiers temporaires d'AdGuard prêts à remonter dans un commit — le plus instructif des quatre, puisque ce n'était même pas un « contrôle ».

Le motif est le même dans les trois cas : la panne se manifeste par un succès. C'est ce que ces deux commandes rendent visible.

Répondre à la question 1 : les scripts et les units​

cd /mnt/ssd/config/ansible
ansible-playbook playbooks/deploy-scripts.yml --check --diff
ansible-playbook playbooks/deploy-systemd.yml --limit penny --check --diff

--check n'écrit rien. changed=0 signifie que chaque fichier déclaré est identique à sa copie live. Un changed affiche le diff : lis-le avant d'appliquer. Trois fois pendant ce chantier, la copie live était la bonne et le dépôt était en retard — appliquer aurait détruit un correctif de production.

Pour appliquer, la même commande sans --check. L'idempotence se prouve en relançant le --check juste après : il doit rendre changed=0.

Ce que ça couvre​

Recompté le 2026-09-25 : grep -c 'src:' ansible/inventory/group_vars/{penny,pve_nodes}.yml.

ManifestePortée
ansible/inventory/group_vars/penny.yml108 paires : 45 scripts + 60 units systemd + 3 règles udev sur la Pi
ansible/inventory/group_vars/pve_nodes.yml5 scripts sur galahad et lancelot, soit 10 paires

La page annonçait 51 units au lieu de 60. Les neuf de plus sont les sondes posées entre le 06/09 et le 24/09 — le manifeste, lui, les avait bien suivies. C'est le même écart que celui du tableau des timers, vu par un autre bout : ce qui pourrit, ce sont les comptes recopiés à la main, pas les déclarations.

Le manifeste est explicite, jamais un glob : scripts/ contient aussi des tests, des migrations à usage unique et des scripts destinés aux LXC, qui n'ont rien à faire sur un hôte.

Ce que ça ne couvre pas​

Sept scripts tournent sur penny sans exister dans git — une réinstallation les perdrait. La liste vit dans homelab-config/ansible/NON-VERSIONNES.md. Ils sont hors manifeste par construction : on ne peut pas déclarer la source d'un fichier qui n'en a pas.

setup-node-exporter.sh est volontairement hors manifeste. Le dépôt y est en avance d'un correctif nfsd/systemd du 27/08 que les nœuds n'ont pas. L'y ajouter ferait de son premier apply un déploiement, pas une réparation de dérive. Déployer un correctif est une décision ; l'ajouter au manifeste suffira le jour où elle est prise.

Le template Packer n'est couvert par rien, et c'est normal : il n'a aucun consommateur. Le cluster n'héberge aucune VM — qm list est vide sur les deux nœuds — et son build n'a jamais tourné. Il existe pour que la première VM ne soit pas montée à la main. Vérifier l'espace sur galahad:local avant tout build : il était à 78 % le 2026-09-03.

sync-grafana-config.sh n'y est pas non plus, et c'est volontaire : il n'a aucune copie live, il s'invoque depuis le dépôt. Le manifeste déclare les fichiers dont une copie existe et doit rester en phase ; y ajouter celui-là créerait une surface de dérive là où il n'y en a pas.

Répondre à la question 2 : les dix LXC​

cd /mnt/ssd/config/iac/terraform
export $(grep -E '^PROXMOX_VE_' /run/homelab/.env | xargs)
tofu plan
N'exportez que les trois variables du provider

Un set -a; . /run/homelab/.env; set +a — la forme qui figurait ici jusqu'au 2026-09-04 — met tous les secrets du homelab dans l'environnement du shell, de tous ses enfants et de /proc/<pid>/environ : token Cloudflare, clef Tailscale, secrets Outline, Forgejo. Ils étaient 37 à l'époque, ils sont 35 au 2026-09-25 (ceux de Firefly ont été purgés avec le service). Pour un plan qui en a besoin de trois.

C'est le même raisonnement que « un secret qui passe par un terminal atterrit dans un historique, une transcription et un scrollback », appliqué une ligne plus loin — et il avait été manqué là.

Attendu : No changes. Your infrastructure matches the configuration.

Un plan non vide signifie que quelqu'un a modifié un conteneur à la main. Un fichier par conteneur, nommé <VMID>-lxc-<nom>.tf.

Un plan vide ne prouve pas que le cluster est tel qu'on le croit

Il prouve que les conteneurs déclarés correspondent à leur déclaration. Terraform ne compare que ce qu'il connaît — un conteneur créé à la main lui est invisible.

Mesuré le 2026-09-04 : le LXC 110 « securo » tournait sur lancelot, aucune déclaration ne le mentionnait, et tofu plan répondait « No changes ». La page que vous lisez affirmait le contraire ; c'était faux.

(Ce cas précis est refermé : 110-lxc-securo.tf existe depuis, et les dix LXC sont déclarés au 2026-09-25. L'exemple reste parce que le défaut qu'il illustre, lui, est permanent — la prochaine création manuelle sera tout aussi invisible.)

La moitié manquante vient de control-drift-check.sh, qui compare la liste des invités de chaque nœud aux fichiers de déclaration présents et signale les absents — sans token ni tofu. Il faut les deux : tofu plan pour la fidélité, control-drift-check.sh pour l'exhaustivité.

Le garde-fou est matériel, pas déclaratif​

Le token API terraform@pve!iac a le rôle PVEAuditor, en lecture seule. Vérifié : une tentative d'écriture sur l'API rend HTTP 403. Un tofu apply lancé par erreur échoue donc côté Proxmox, indépendamment de toute discipline côté client. prevent_destroy est posé sur les dix ressources en second filet.

La limite à connaître avant tout apply​

Quatre conteneurs portent des clés LXC que le provider ne modélise pas :

lxc.cgroup2.devices.allow: c 10:200 rwm
lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file

C'est le passthrough TUN dont Tailscale a besoin en LXC non privilégié. Il concerne les CT 100 (dns-failover) et 107 (waterline) ; il concernait aussi 104 (zomboid) et 105 (sucre), détruits depuis.

Terraform ne les voit pas, donc il ne les détruira pas. Mais une recréation les perdrait en silence, et Tailscale tomberait sur ces quatre conteneurs sans que rien ne le signale. C'est la raison d'être du prevent_destroy, et la raison pour laquelle passer le token en écriture est une décision à prendre les yeux ouverts.

L'état, et pourquoi il est sauvegardé à part​

terraform.tfstate contient le token en clair : il est gitignoré, donc sans aucune protection git. Il a vécu une journée sans sauvegarde du tout, parce qu'on avait supposé que « c'est sous /mnt/ssd/config, donc restic le prend ». Faux : homelab_backup.sh sauvegarde une liste explicite de sous-répertoires, et iac/ n'y figurait pas. Il y est depuis.

La leçon dépasse ce fichier : être sous le répertoire sauvegardé ne prouve rien quand la sauvegarde procède par liste.

En dernier recours, l'état se reconstruit par ré-import — plus lent, mais rien n'est perdu. Voir homelab-config/iac/README.md.

La troisième question : reconstruire, et pas seulement maintenir​

Les deux commandes ci-dessus maintiennent en phase un système déjà installé. Elles ne savent pas partir d'une machine nue. Depuis le 2026-09-06, trois playbooks de plus s'en chargent — et un quatrième juge le résultat.

PlaybookCe qu'il poseÉprouvé ?
bootstrap-base.ymlpaquets, Docker, sops, Tailscale installé mais pas enrôlé, Alloyoui, sur banc jetable
bootstrap-stack.ymlsocket-proxy, traefik, authelia, adguard, homepageoui, sur banc jetable
bootstrap-pi.yml/boot/firmware, /etc/modules, fstab, zram DietPi, paquets matériel Pinon — jamais exécuté pour de vrai
verify.ymlrien : il jugeoui, sur les deux cibles
cd /mnt/ssd/config/ansible
ansible-playbook playbooks/verify.yml -e cible=penny

Exécuté le 2026-09-06 : ok=11 failed=0 sur penny (37 clefs descellées) et sur le banc LXC 111 (14 clefs). Sur le banc, le cycle complet détruire-recréer-reconstruire-juger tient en 490 s sans intervention (reset 58 s, couche 1 316 s, couche 2 105 s, verdict 11 s). Sur penny, seul verify.yml a réellement tourné — voir les réserves plus bas.

Ce que verify.yml fait, et que les healthchecks ne font pas​

Il ne demande pas « le conteneur est-il healthy ? » mais « le service sert-il ? ». Deux mesures de ce homelab imposent la distinction : un 302 d'Authelia vient du middleware et non du backend (CyberChef est resté mort 24 h derrière un 302), et loki-replica rend 503 sur /ready en servant normalement.

Il porte surtout un contrôle négatif : un hôte en .invalid — non délégable par construction, donc qui ne pourra jamais correspondre à un routeur réel — doit obtenir une réponse différente du vrai domaine. Sans cette contre-épreuve, la sonde ne distinguerait pas un routage d'une redirection inconditionnelle. Mesuré sur penny : vrai domaine 302, hôte inexistant 000.

Le banc jetable, et ce qu'il ne peut pas prouver​

Le banc est un LXC amd64 sur galahad, monté et détruit par scripts/banc-test.sh (create|destroy|reset|status). Il porte 192.168.1.28/32 sur sa loopback — l'adresse de penny — pour que Traefik s'y lie comme en production, avec arp_ignore=1 posé avant l'adresse pour qu'il ne réponde jamais à l'ARP du LAN à la place de penny.

Le critère d'acceptation le plus important du chantier n'était pas que verify.yml passe : c'est qu'il échoue sur un banc nu. Un juge qui rend vert sur une machine vide ne juge rien.

Ce qu'un LXC amd64 ne peut pas porter : /boot/firmware, une carte SD, vcgencmd, DietPi. La couche 3 est donc appuyée sur penny — penny comme oracle, --check --diff, gardes de pré-vol éprouvées par cinq fautes provoquées — mais appuyer n'est pas éprouver. Ces appuis prouvent la non-régression, pas la reconstruction.

Trois réserves à connaître avant de s'en servir​

La couche 1 ne tourne pas sur penny. paquets_base n'est déclaré que pour le groupe banc ; visée sur penny, la couche s'arrête sur 'paquets_base' is undefined (mesuré le 2026-09-06). Ce n'est pas un oubli à combler d'un copier-coller : la liste du banc exclut délibérément les paquets matériel Pi, que la couche 3 reprend à son compte. Décider ce que penny reçoit est une décision, pas une variable.

Les trois couches refusent de tourner sans bootstrap_autorise. Déclaré par groupe dans inventory/homelab.yml : vrai pour le banc, faux pour penny et pour les nœuds Proxmox. Le cran est délibéré — la couche 1 installerait des dizaines de paquets et trois dépôts apt tiers sur la production. Une cible sans déclaration échoue au lieu de passer : le garde juge la chose protectrice, jamais la chose dangereuse.

Rien ne rejoue le banc automatiquement. La CI (.github/workflows/ci.yml) exerce la logique pure du pré-vol et de banc-test.sh, pas le cycle réel : celui-ci demande galahad, six gigaoctets et huit minutes. Dire « éprouvé à chaque campagne » serait une intention ; ce qui est vrai, c'est « éprouvé le 2026-09-06 ».

Le parcours complet de reprise — matériel, coffre, YubiKey, ordre de démarrage — reste dans la procédure break-glass.

Un garde-fou doit hurler, pas hausser les épaules​

C'est la seule règle que ce chantier a produite, et elle a coûté cinq occurrences pour être admise — la cinquième, la plus chère, a été trouvée le 2026-09-06.

repo-drift-check.sh porte désormais une garde qui vérifie que les répertoires et fichiers qu'il surveille existent encore, et remonte une dérive sinon. Sans elle, un renommage le rendait muet : son find ne trouvait rien, son 2>/dev/null avalait l'erreur, et il rendait « OK » sans plus rien comparer.

Même correction dans ci.yml et pre-merge-check.sh : la boucle qui validait les fichiers compose testait leur présence par if [ -f ] sans branche d'échec. Un chemin mort y était silencieusement sauté. Elle sort désormais en erreur.

La cinquième n'était même pas un contrôle : c'était la boucle qui construit la liste de chemins de homelab_backup.sh.

[ -d "$p" ] && BACKUP_PATHS+=("$p")

Un && qui échoue ne dit rien. Toute entrée de type fichier y était donc écartée en silence, et la sauvegarde se déclarait « OK » — dont l'état OpenTofu des dix LXC, absent du dépôt R2 pendant deux nuits. C'est-à-dire : l'objet même de la question 2 de cette page n'était plus sauvegardé, et rien ne le disait. Récit et correctif dans Sauvegardes.

Quand vous écrivez un contrôle, la question n'est pas « détecte-t-il le problème ? » mais « que rend-il si sa cible disparaît ? ». S'il rend vert, ce n'est pas un contrôle. Et la question vaut aussi pour ce qui n'a pas l'air d'un contrôle : une liste filtrée est un contrôle qui s'ignore.

Un piège adjacent : ne pas sonder /ready sur loki-replica​

Mesuré le 2026-09-03 : loki-replica rend HTTP 503 sur /ready (« Ingester not ready ») alors qu'il tourne depuis des heures et sert activement des requêtes. /metrics et /loki/api/v1/labels rendent 200, et les Alloy poussent sans problème.

Ce conteneur n'a aucun healthcheck déclaré, et autoheal tourne dans la stack. Ajouter un healthcheck basé sur /ready — le choix évident — le marquerait unhealthy en permanence et autoheal le redémarrerait en boucle. Si un healthcheck devient souhaitable, le baser sur /loki/api/v1/labels.