diff --git a/CHANGELOG.md b/CHANGELOG.md index 054afe7..9e76cc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,68 @@ # CHANGELOG — Set-OPS +## 2026-08-25 — Le plancher survit au redémarrage, et la zone dit les vraies adresses + +**46 preuves.** Le découpage du site en quatre zones a déplacé cinq machines ; ni le +plancher `/etc/hosts` ni la zone DNS n'avaient suivi. Quatre défauts, tous dans le moteur. + +### Le plancher était effacé à chaque démarrage — et le correctif d'hier n'en était pas un + +`hosts_statiques` posait `/etc/cloud/cloud.cfg.d/99-setops-hosts.cfg` avec +`manage_etc_hosts: false`, pendant que `cloud_init` posait `99_setops.cfg` avec `true`. +Dans `cloud.cfg.d` l'ordre est **lexical** et le dernier gagne : `-` vaut 0x2D, `_` vaut +0x5F — le `true` l'emportait. On a donc d'abord retiré la clé de `cloud_init` (le rôle qui +**possède** le fichier décide), puis renommé notre fragment `zz-` pour passer après le +`99_chezlepro.cfg` du gabarit doré. + +**Et ça ne suffisait toujours pas.** Redémarrage d'épreuve : le plancher, encore effacé. +La cause réelle est ailleurs — Proxmox inscrit `manage_etc_hosts: true` dans la +**user-data** de son lecteur cloud-init, et la user-data prime sur `cloud.cfg.d` tout +entier. Aucun fragment ne pouvait gagner ; renommer pour parler en dernier ne servait à +rien, le dernier mot n'appartenant pas à ce répertoire. + +Ce que cloud-init régénère, il le régénère depuis `hosts.debian.tmpl` — le gabarit le +documente lui-même. `hosts_statiques` le pose donc désormais avec le **même contenu** que +`/etc/hosts`, et une garde compare les deux à chaque passage. Redémarrage d'épreuve : les +neuf entrées sont là. + +*La garde précédente affirmait « conforme » en mesurant l'ordre lexical — vrai, et sans +rapport avec ce qui se passait. Une garde qui mesure la mauvaise chose est pire +qu'aucune.* + +### La zone DNS ne publiait pas les noms de service + +`forge.genese.internal` et `pki.genese.internal` — des noms que les certificats portent et +que les clients appellent — n'avaient **aucun enregistrement**. Seul le plancher savait +les résoudre. Trois causes empilées : + +- le plan du site coupait `serveur_powerdns_publier_expositions`, au motif que « le site + n'a pas d'edge » — ce qui confondait *public* et *exposé* ; +- `expositions_des_applications` rendait `domaine: None` faute de `domaines.yml`, et le + modèle de zone écarte les expositions dont le domaine n'est pas la zone. Le repli existe + désormais : **sans domaine public déclaré, le domaine est celui que porte le FQDN** — + symétrique du repli déjà écrit pour `edge` ; +- `serveur_powerdns` exigeait les deux registres et échouait si `domaines.yml` manquait — + le même tout-ou-rien que `hosts_statiques` avait corrigé le même jour. + +Puis `named-checkzone` a refusé la zone : `dns.genese.internal` héritait d'un `CNAME` par +défaut du rôle **et** d'un `A` par exposition. La garde a bien joué son rôle — elle a +arrêté une zone cassée avant qu'elle soit servie. Le plan l'emporte désormais sur le +défaut du rôle. + +*Un service ne doit pas dépendre d'un plancher pour être joignable : le plancher est un +filet, pas le sol.* + +### Vérification + +Les huit noms — cinq machines et trois services — résolvent vers les bonnes adresses +depuis les cinq hôtes, **par le plancher et par le DNS**, et le plancher survit au +redémarrage. + +**Reste nommé, pas corrigé** : la zone INVERSE. `serveur_powerdns_zone_inverse` dérive +d'un supernet `/16` — la forme d'un tenant. Un site déclare plusieurs `/24` et n'a pas de +supernet unique : aucune zone inverse n'est générée, et les machines du site restent +anonymes à l'envers. + ## 2026-08-25 — Le pare-feu Proxmox ne s'arme que dans le SDN **45 preuves.** Le découpage du site en quatre zones a révélé un défaut qui dormait dans diff --git a/docs/audit/preuve-2026-08-25.md b/docs/audit/preuve-2026-08-25.md index 1968466..f574e90 100644 --- a/docs/audit/preuve-2026-08-25.md +++ b/docs/audit/preuve-2026-08-25.md @@ -7,7 +7,7 @@ > [`docs/audit/affirmations.md`](affirmations.md). - **Instance** : `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance` — inventaire `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance/inventories/production/hosts.yml` -- **Verdict** : ✅ CONFORME (44 OK · 0 echec · 1 saute) +- **Verdict** : ✅ CONFORME (45 OK · 0 echec · 1 saute) ## Preuves @@ -58,6 +58,7 @@ | P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 5 machine(s) du plan retrouvees, 56 regle(s) du site. | | P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 4 integration(s) appliquent leur serveur avant leurs clients. | | P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). | +| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos | ## Couverture des affirmations ✅ du registre diff --git a/roles/cloud_init/defaults/main.yml b/roles/cloud_init/defaults/main.yml index 485662e..6d7c303 100644 --- a/roles/cloud_init/defaults/main.yml +++ b/roles/cloud_init/defaults/main.yml @@ -1,5 +1,8 @@ --- cloud_init_ssh_pwauth: false -cloud_init_manage_etc_hosts: true +# `cloud_init_manage_etc_hosts` A ETE RETIREE (2026-08-25) : elle entrait en conflit avec +# le fragment de `hosts_statiques`, qui possede /etc/hosts et decide seul si cloud-init +# peut y toucher. La garder ici, sans effet, serait un mensonge — une variable qu'on +# renseigne et qui ne change rien est pire que son absence. cloud_init_disable_root: true cloud_init_ssh_deletekeys: true diff --git a/roles/cloud_init/templates/99_setops.cfg.j2 b/roles/cloud_init/templates/99_setops.cfg.j2 index cf9f192..38b71e2 100644 --- a/roles/cloud_init/templates/99_setops.cfg.j2 +++ b/roles/cloud_init/templates/99_setops.cfg.j2 @@ -3,7 +3,23 @@ datasource_list: [ NoCloud, ConfigDrive, None ] preserve_hostname: false -manage_etc_hosts: {{ cloud_init_manage_etc_hosts | bool | lower }} +# `manage_etc_hosts` N'EST PAS ICI, ET C'EST VOULU (2026-08-25). +# +# Ce fragment le posait a `true`, pendant que `hosts_statiques` en posait un autre a +# `false`. Deux ecritures, deux fichiers, un desaccord — et dans `cloud.cfg.d` l'ordre +# est LEXICAL : `99-setops-hosts.cfg` (`-`, 0x2D) est lu avant `99_setops.cfg` (`_`, +# 0x5F). C'est donc celui-ci qui gagnait, et cloud-init reecrivait /etc/hosts a chaque +# demarrage — effacant le plancher de resolution en silence. +# +# Le defaut ne se voit qu'au redemarrage SUIVANT, et il se manifeste ailleurs : un `apt +# update` qui ne resout plus le cache, un certificat dont le nom ne pointe nulle part. +# Mesure du 2026-08-25 : les cinq machines du site redemarrees, /etc/hosts reduit aux +# entrees Debian par defaut, avec un `127.0.1.1 site-pki-01.chezlepro.ca` venu du +# `searchdomain` Proxmox — pas meme le bon domaine. +# +# LE ROLE QUI POSSEDE LE FICHIER DECIDE : `hosts_statiques` ecrit /etc/hosts, c'est donc +# lui qui dit si cloud-init peut y toucher. Quand il est inactif, rien ne l'interdit plus +# et cloud-init reprend son comportement par defaut — ce qui est le bon repli. disable_root: {{ cloud_init_disable_root | bool | lower }} ssh_pwauth: {{ cloud_init_ssh_pwauth | bool | lower }} diff --git a/roles/hosts_statiques/tasks/main.yml b/roles/hosts_statiques/tasks/main.yml index abe3ee7..c185364 100644 --- a/roles/hosts_statiques/tasks/main.yml +++ b/roles/hosts_statiques/tasks/main.yml @@ -14,9 +14,22 @@ # On le desactive par un fragment plutot qu'en editant cloud.cfg : le gabarit reste # intact, et `apt purge cloud-init` ou une remise a zero rend la machine a son etat # d'origine. +# +# `zz-` ET NON `99-` (2026-08-25). Cloud-init lit `cloud.cfg.d` dans l'ordre LEXICAL, et +# le DERNIER a parler gagne. Notre fragment s'appelait `99-setops-hosts.cfg` ; le gabarit +# dore en porte un autre, `99_chezlepro.cfg`, qui pose `manage_etc_hosts: true`. Or `-` +# vaut 0x2D et `_` vaut 0x5F : le fragment du gabarit etait lu APRES le notre, et gagnait. +# +# Mesure du 2026-08-25 : les cinq machines du site redemarrees avaient perdu tout leur +# plancher malgre notre fragment — il etait bien la, simplement sans effet. Un fichier +# present et ignore est le pire des deux mondes : il rassure sans rien garantir. +# +# `zz-` passe apres tout prefixe numerique ET apres `_`. On ne cherche donc plus a +# connaitre les fragments des autres : on parle en dernier, quels qu'ils soient. La garde +# plus bas verifie que c'est encore vrai sur la machine. - name: Empêcher cloud-init de réécrire le plancher à chaque démarrage ansible.builtin.copy: - dest: /etc/cloud/cloud.cfg.d/99-setops-hosts.cfg + dest: /etc/cloud/cloud.cfg.d/zz-setops-hosts.cfg content: | # GÉNÉRÉ par Set-OPS (rôle hosts_statiques). NE PAS éditer à la main. # /etc/hosts est le PLANCHER DE RÉSOLUTION de l'écosystème, dérivé de l'inventaire. @@ -27,6 +40,31 @@ mode: "0644" when: hosts_statiques_actif | bool +# CE FRAGMENT EST DESORMAIS LE SEUL A DECIDER (2026-08-25). +# +# `cloud_init` en ecrivait un second (`99_setops.cfg`) qui posait `manage_etc_hosts: true`. +# Dans `cloud.cfg.d` l'ordre est LEXICAL : `-` (0x2D) passe avant `_` (0x5F), donc le +# fragment de `cloud_init` etait lu EN DERNIER et gagnait. Le plancher etait efface a +# chaque demarrage, et ca ne se voyait qu'au redemarrage suivant — sous la forme d'un +# `apt update` qui ne resolvait plus, ou d'un nom de service qui ne pointait nulle part. +# +# LE RETRAIT DOIT ETRE AUSSI EXPLICITE QUE LA POSE. Sans la tache ci-dessous, desactiver +# `hosts_statiques` laissait le fragment en place : la machine gardait un /etc/hosts fige +# que plus personne ne tenait a jour, et cloud-init n'avait plus le droit de le reprendre. +# Un etat que ni l'un ni l'autre n'assume. +- name: Rendre /etc/hosts à cloud-init quand le plancher est désactivé + ansible.builtin.file: + path: /etc/cloud/cloud.cfg.d/zz-setops-hosts.cfg + state: absent + when: not (hosts_statiques_actif | bool) + +# L'ancien nom, qui ne parlait pas en dernier. Le laisser en place n'aurait rien casse, +# mais deux fichiers disant la meme chose sont un desaccord qui attend son heure. +- name: Retirer l'ancien fragment, qui ne parlait pas en dernier + ansible.builtin.file: + path: /etc/cloud/cloud.cfg.d/99-setops-hosts.cfg + state: absent + # Alias d'exposition (best-effort) : dérivés du plan si celui-ci est disponible. - name: Vérifier la présence des registres du plan (sur le nœud de contrôle) ansible.builtin.stat: @@ -85,3 +123,76 @@ group: root mode: "0644" when: hosts_statiques_actif | bool + +# LE SEUL ENDROIT QUI TIENNE AU REDEMARRAGE (2026-08-25). +# +# Le fragment `cloud.cfg.d` ne suffit pas, et c'est mesure : Proxmox inscrit +# `manage_etc_hosts: true` dans la USER-DATA de son lecteur cloud-init, et la user-data +# prime sur tout fragment de `/etc/cloud/cloud.cfg.d`. Le plancher etait donc efface a +# chaque demarrage malgre le fragment — d'abord `99-setops-hosts.cfg`, puis son +# remplacant `zz-` : renommer pour parler en dernier ne changeait rien, puisque le dernier +# mot n'appartenait pas a ce repertoire. +# +# Ce que cloud-init regenere, il le regenere DEPUIS CE GABARIT. On le pose donc avec le +# meme contenu que /etc/hosts : le fichier de la machine et le gabarit qui le reecrira +# disent la meme chose, quelle que soit la valeur de `manage_etc_hosts`. +# +# Le gabarit d'origine le documente lui-meme : « make changes to the master file in +# /etc/cloud/templates/hosts.debian.tmpl ». +- name: Poser le plancher dans le gabarit maître de cloud-init (il survit au démarrage) + ansible.builtin.template: + src: hosts.debian.tmpl.j2 + dest: /etc/cloud/templates/hosts.debian.tmpl + owner: root + group: root + mode: "0644" + when: + - hosts_statiques_actif | bool + - ansible_facts.os_family | default('') == 'Debian' + +# LA GARDE MESURE LE FAIT QUI COMPTE (2026-08-25). +# +# La garde precedente verifiait que notre fragment `cloud.cfg.d` parlait en dernier. C'est +# vrai — et sans effet, puisque la user-data de la source de donnees prime sur ce +# repertoire tout entier. Une garde qui mesure la mauvaise chose est pire qu'aucune : elle +# affirme « conforme » pendant que le defaut opere. +# +# Ce qui compte est que le fichier ET le gabarit qui le reecrira portent les MEMES +# entrees. On les compare donc, ligne d'adresse par ligne d'adresse. +- name: Relever les entrées du plancher et celles du gabarit maître + ansible.builtin.shell: + cmd: >- + set -o pipefail; + for f in /etc/hosts /etc/cloud/templates/hosts.debian.tmpl; do + printf '%s=' "$f"; + grep -cE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+[[:space:]]' "$f" 2>/dev/null || printf '0'; + printf '\n'; + done + executable: /bin/bash + register: hosts_statiques_accord + changed_when: false + when: + - hosts_statiques_actif | bool + - ansible_facts.os_family | default('') == 'Debian' + +- name: Refuser que le plancher et son gabarit maître divergent + ansible.builtin.assert: + that: + - (hosts_statiques_accord.stdout_lines | select('search', '^/etc/hosts=') + | first | default('=0')).split('=')[1] | int > 1 + - (hosts_statiques_accord.stdout_lines | select('search', '^/etc/hosts=') + | first | default('=0')).split('=')[1] + == (hosts_statiques_accord.stdout_lines | select('search', 'hosts.debian.tmpl=') + | first | default('=-1')).split('=')[1] + fail_msg: >- + Le plancher et le gabarit maître de cloud-init ne portent pas le même nombre + d'entrées ({{ hosts_statiques_accord.stdout_lines | default([]) | join(' | ') }}). + Cloud-init régénère /etc/hosts depuis le gabarit à chaque démarrage : la divergence + ne se verrait qu'au prochain redémarrage, et se manifesterait ailleurs — un `apt` + qui ne résout plus, un certificat dont le nom ne pointe nulle part. + success_msg: >- + Plancher et gabarit maître d'accord + ({{ hosts_statiques_accord.stdout_lines | default([]) | join(' | ') }}). + when: + - hosts_statiques_actif | bool + - ansible_facts.os_family | default('') == 'Debian' diff --git a/roles/hosts_statiques/templates/hosts.debian.tmpl.j2 b/roles/hosts_statiques/templates/hosts.debian.tmpl.j2 new file mode 100644 index 0000000..1f31ef2 --- /dev/null +++ b/roles/hosts_statiques/templates/hosts.debian.tmpl.j2 @@ -0,0 +1,17 @@ +## template:jinja +# GABARIT MAÎTRE de /etc/hosts — GÉNÉRÉ par Set-OPS (rôle hosts_statiques). +# +# Cloud-init régénère /etc/hosts À CHAQUE DÉMARRAGE à partir de CE fichier lorsque +# `manage_etc_hosts` vaut vrai. Et il vaut vrai : Proxmox l'inscrit dans la user-data de +# son lecteur cloud-init, laquelle prime sur tout fragment de `/etc/cloud/cloud.cfg.d`. +# Aucun fragment ne peut donc gagner — ni l'ancien `99-setops-hosts.cfg`, ni son +# remplaçant `zz-`. Le seul endroit qui tienne est ici. +# +# Le contenu est le même que celui de /etc/hosts, à la ligne près : une seule source. +# +# CE QU'ON N'ÉCRIT PAS ICI : la ligne `127.0.1.1 {{ '{{fqdn}}' }} {{ '{{hostname}}' }}` du gabarit +# d'origine. Elle fait pointer le FQDN de la machine sur la boucle locale, avec le domaine +# du `searchdomain` Proxmox — `site-ops-01.chezlepro.ca` au lieu de +# `site-ops-01.genese.internal`. `hostname -f` rendrait alors un nom que personne ne peut +# vérifier, et c'est exactement le piège que le plancher documente déjà. +{% include 'hosts.j2' %} diff --git a/roles/serveur_powerdns/tasks/main.yml b/roles/serveur_powerdns/tasks/main.yml index 6e273c2..c4a414a 100644 --- a/roles/serveur_powerdns/tasks/main.yml +++ b/roles/serveur_powerdns/tasks/main.yml @@ -32,14 +32,39 @@ mode: "0644" notify: Validate and reload PowerDNS -- name: Charger les registres applications et domaines (expositions) - ansible.builtin.include_vars: - file: "{{ item }}" +# CHAQUE REGISTRE EST CHARGE POUR LUI-MEME (2026-08-25). +# +# Ce `include_vars` listait les deux fichiers en dur et echouait si l'un manquait. +# `domaines.yml` declare les domaines PUBLICS, et un ecosysteme peut legitimement n'en +# avoir aucun — c'est le cas du SITE, dont tous les services sont internes. +# +# Le defaut est reste invisible tant que le site gardait `publier_expositions: false` ; +# il est apparu a la seconde ou on a voulu publier ses noms de service. C'est le MEME +# defaut que `hosts_statiques` a corrige le meme jour, dans les memes termes : un registre +# absent n'est pas une faute, il est simplement absent. +- name: Vérifier la présence des registres du plan (sur le nœud de contrôle) + ansible.builtin.stat: + path: "{{ item }}" + register: serveur_powerdns_plan + delegate_to: localhost # les registres du plan vivent sur le nœud de contrôle + become: false # lire un fichier ne nécessite pas de privilèges loop: - "{{ setops_plan_dir }}/applications.yml" - "{{ setops_plan_dir }}/domaines.yml" + loop_control: + label: "{{ item | basename }}" when: serveur_powerdns_publier_expositions | bool +- name: Charger les registres applications et domaines (si présents) + ansible.builtin.include_vars: + file: "{{ item.item }}" + loop: "{{ serveur_powerdns_plan.results | default([]) }}" + loop_control: + label: "{{ item.item | basename }}" + when: + - serveur_powerdns_publier_expositions | bool + - item.stat.exists | default(false) + - name: Dériver les A d'exposition (FQDN publics -> edge qui les sert) ansible.builtin.set_fact: serveur_powerdns_expositions: >- diff --git a/roles/serveur_powerdns/templates/zone.db.j2 b/roles/serveur_powerdns/templates/zone.db.j2 index 6e802a0..0a0f7b7 100644 --- a/roles/serveur_powerdns/templates/zone.db.j2 +++ b/roles/serveur_powerdns/templates/zone.db.j2 @@ -11,7 +11,26 @@ $TTL {{ serveur_powerdns_ttl }} @ IN NS {{ serveur_powerdns_nameserver }}.{{ serveur_powerdns_zone }}. {{ serveur_powerdns_nameserver }} IN A {{ hostvars[groups['serveur_powerdns'][0]].ansible_host | default('127.0.0.1') }} -{% for record in serveur_powerdns_records %} +{# LE PLAN L'EMPORTE SUR LE DEFAUT DU ROLE (2026-08-25). + + `serveur_powerdns_records` porte un defaut de commodite : `dns IN CNAME ns1`. Le plan + du site, lui, expose `dns.genese.internal` pour `serveur_resolveur` — ce qui produit un + `dns IN A`. Un nom ne peut pas porter a la fois un CNAME et un A : `named-checkzone` a + refuse la zone (« CNAME and other data »), et PowerDNS ne l'a pas servie du tout. + + La garde a bien fonctionne — elle a arrete une zone cassee avant qu'elle soit servie. + Mais elle laissait le DNS du site sans zone tant que la collision durait. + + On ecarte donc le record statique quand le plan expose le meme nom. C'est le sens de + marche du depot : ce qui est DECLARE AU PLAN prime sur ce qu'un role suppose par + defaut. Les deux pointaient de toute facon vers la meme machine. #} +{% set _exposes = [] %} +{% if serveur_powerdns_publier_expositions | default(false) %} +{% for expo in serveur_powerdns_expositions | default([]) if expo.domaine == serveur_powerdns_zone %} +{% set _ = _exposes.append(expo.fqdn[:-(serveur_powerdns_zone | length + 1)]) %} +{% endfor %} +{% endif %} +{% for record in serveur_powerdns_records if record.name not in _exposes %} {{ record.name }} IN {{ record.type }} {{ record.value }} {% endfor %} diff --git a/scripts/inventory_rules.py b/scripts/inventory_rules.py index 0de7b24..8888c84 100644 --- a/scripts/inventory_rules.py +++ b/scripts/inventory_rules.py @@ -494,6 +494,20 @@ def expositions_des_applications(applications: dict, domaines: dict, edge: str | edge_dom = conf.get("edge") or app.get("groupe") if edge is not None and edge_dom != edge: continue + # SANS DOMAINE PUBLIC DECLARE, LE DOMAINE EST CELUI QUE PORTE LE FQDN + # (2026-08-25). Meme repli que pour `edge` juste au-dessus, et il manquait. + # + # `domaine_parent` cherche le FQDN parmi les domaines PUBLICS. Le SITE n'en a + # aucun — tous ses noms sont internes — donc `dom` valait None, et le modele + # de zone PowerDNS, qui ne retient que les expositions dont le domaine EST sa + # zone, les ecartait toutes. + # + # Consequence mesuree le 2026-08-25 : `forge.genese.internal` et + # `pki.genese.internal` n'avaient aucun enregistrement A. Seul le plancher + # /etc/hosts savait les resoudre — or un service ne doit pas dependre d'un + # plancher pour etre joignable ; le plancher est un filet, pas le sol. + if not dom and "." in str(fqdn): + dom = str(fqdn).split(".", 1)[1] resultat.append({ "fqdn": fqdn, "application": app_id, "groupe": app.get("groupe"), "hote": app.get("hote"), "port": app.get("port"), diff --git a/scripts/prouver.py b/scripts/prouver.py index 58c3f4f..48bce26 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -1143,6 +1143,60 @@ def preuve_parefeu_proxmox_sdn_seulement() -> tuple[bool, str]: f"({len(cas)} cas evalues, dont un qui doit rendre VRAI).") +def preuve_un_seul_maitre_du_plancher() -> tuple[bool, str]: + """Un seul role decide si cloud-init a le droit de reecrire /etc/hosts. + + POURQUOI (2026-08-25). Deux roles ecrivaient un fragment dans `/etc/cloud/cloud.cfg.d` + et se contredisaient : `hosts_statiques` posait `manage_etc_hosts: false`, + `cloud_init` posait `true`. Or l'ordre y est LEXICAL — `99-setops-hosts.cfg` (`-`, + 0x2D) est lu AVANT `99_setops.cfg` (`_`, 0x5F). C'est donc le `true` qui gagnait, et + cloud-init reecrivait /etc/hosts a chaque demarrage. + + Le plancher de resolution disparaissait ainsi en silence, et le defaut ne se + manifestait qu'au redemarrage suivant, ailleurs : un `apt update` qui ne resout plus + le cache, un certificat dont le nom ne pointe nulle part. Mesure : les cinq machines + du site redemarrees le 2026-08-25 n'avaient plus que les entrees Debian par defaut. + + Le role qui POSSEDE le fichier decide. Cette preuve refuse qu'un second s'en mele : + deux ecritures, meme d'accord aujourd'hui, sont un desaccord qui attend son heure. + """ + ecrivains: list[str] = [] + for chemin in sorted((RACINE / "roles").rglob("*")): + if not chemin.is_file() or chemin.suffix not in (".yml", ".yaml", ".j2"): + continue + for ligne in chemin.read_text(encoding="utf-8", errors="replace").splitlines(): + nu = ligne.strip() + if nu.startswith("#"): + continue # un commentaire explique, il n'ecrit pas + if re.match(r"^manage_etc_hosts\s*:", nu): + ecrivains.append(f"{chemin.relative_to(RACINE)} : {nu}") + if len(ecrivains) != 1: + return False, ( + f"{len(ecrivains)} ecriture(s) de `manage_etc_hosts` dans les roles — il en " + f"faut exactement une, sinon l'ordre lexical de `cloud.cfg.d` tranche a notre " + f"place :\n - " + "\n - ".join(ecrivains or ["aucune : plus personne " + "n'empeche cloud-init d'effacer le plancher au demarrage"])) + + # ET LE FRAGMENT NE SUFFIT PAS. Proxmox inscrit `manage_etc_hosts: true` dans la + # USER-DATA de son lecteur cloud-init, et la user-data prime sur tout `cloud.cfg.d`. + # Le plancher a donc ete efface a chaque demarrage malgre le fragment — d'abord sous + # le nom `99-`, puis sous `zz-` : renommer pour parler en dernier ne servait a rien, + # le dernier mot n'appartenant pas a ce repertoire. + # + # Ce que cloud-init regenere, il le regenere depuis `hosts..tmpl`. Un role + # doit donc poser CE gabarit, sans quoi la correction n'en est pas une. + gabarit = [str(p.relative_to(RACINE)) for p in (RACINE / "roles").rglob("*") + if p.is_file() and "hosts.debian.tmpl" in p.name] + if not gabarit: + return False, ( + "Aucun role ne pose `/etc/cloud/templates/hosts.debian.tmpl`. Le fragment " + "`cloud.cfg.d` seul ne protege rien : la user-data du lecteur cloud-init " + "(Proxmox y met `manage_etc_hosts: true`) prime sur ce repertoire. Le " + "plancher serait efface au prochain demarrage, sans erreur ni message.") + return True, (f"Un seul maitre du plancher — {ecrivains[0]} ; " + f"et le gabarit maitre est pose ({gabarit[0]}).") + + def preuve_glossaire_enseigne() -> tuple[bool, str]: """Tout mot que le depot emploie devant l'exploitant est explique au glossaire. @@ -1486,6 +1540,8 @@ PREUVES: list[dict] = [ "func": preuve_ordre_des_integrations}, {"id": "P45", "titre": "Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs", "refs": [], "func": preuve_parefeu_proxmox_sdn_seulement}, + {"id": "P46", "titre": "Plancher /etc/hosts : un seul role en decide", + "refs": [], "func": preuve_un_seul_maitre_du_plancher}, {"id": "P43", "titre": "Frontiere : le devis voit les machines du site", "refs": [], "func": preuve_devis_frontiere_du_site}, {"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],