diff --git a/CHANGELOG.md b/CHANGELOG.md index aae38be..cf374a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,71 @@ # CHANGELOG — Set-OPS +## 2026-08-03 (suite 13) — le cluster répond, et il contredit trois hypothèses + +Reconnaissance **en lecture seule** de l'API Proxmox, avec le jeton de la voûte. Trois +valeurs que j'avais devinées étaient fausses, et deux défauts bloquants sont apparus. + +### Ce que le cluster a corrigé + +**Stockages** : `truenas-dbsql` manquait à ma liste. Et le catalogue ne doit offrir que +ceux qui portent `images` — `PBS`, `cephFS`, `local` et `truenas` (iSCSI brut, +`content=none`) n'accueillent pas de disque de VM. + +**Ponts** : `vmbr0` à `vmbr3`, vérifiés présents sur les **trois** nœuds. J'avais omis +`vmbr0` et je n'avais pas contrôlé l'uniformité — un pont partiel est un piège, la VM ne +démarre que sur certains nœuds. + +**Un troisième homonyme** : `web-frontal-01` (vmid 911401) existe déjà hors Set-OPS, sans +pool. Les deux tenants en planifient un chacun. + +### Les pools sont créés + +`Chezlepro-17` et `Technolibre-11`, dérivés comme le reste. Les pools `Env.Tenant` +antérieurs (`Prod.Chezlepro`, `Lab.KBR`…) sont **l'ancien monde : on n'y touche pas**, et +on n'y verse pas la flotte générée — les mélanger effacerait la frontière que Set-OPS +établit. + +Diff constaté sur le cluster : 2 pools ajoutés, 0 retiré, **1 VM sur 38** déplacée — +`infra-pki-01`, qui n'appartenait à aucun pool. + +### Défaut bloquant : `make creer-vm` aurait échoué en 401 + +`proxmoxer` recompose `utilisateur!nom` à partir d'`api_user` et d'`api_token_id`. La +voûte stocke la forme complète, que les playbooks passaient telle quelle — d'où +`ansible@pve!ansible@pve!nom` et un **401 muet**, alors que le même jeton fonctionne en +`curl`. Le diagnostic aurait coûté cher. + +Mesuré des deux côtés avec un module en lecture seule : forme complète = 401, forme +courte = OK, 3 nœuds. Les playbooks normalisent désormais (`split('!') | last`), ce qui +accepte les deux écritures. + +### Le reliquat `proxmox.vault.yml` est supprimé + +Toléré « en compatibilité », il restait le **seul** porteur du jeton chez Technolibre. Et +comme `*.vault.yml` est gitignoré, ce jeton ne voyageait avec aucun dépôt : une voûte +unique (D-19) qui ne l'était pas. + +Migration faite **en mémoire** — aucune valeur en clair sur disque ni affichée — avec +relecture et aller-retour de chiffrement vérifiés avant écriture. Puis suppression du +fichier et retrait des listes de chargement des deux playbooks. Validé par un appel API +réel ne chargeant que `all/vault.yml`. + +### Et la garde qui manquait + +`voute.py verifier` ne comparait que le **gabarit**. C'est pourquoi il annonçait +« complet » pendant qu'un secret vivait ailleurs : le gabarit dit ce qu'il *faudrait*, pas +ce qui *est*. + +Il contrôle maintenant aussi la voûte **réelle**, quand `ANSIBLE_VAULT_PASSWORD_FILE` la +rend déchiffrable — noms de clés seulement, jamais de valeur. Sans mot de passe, la +vérification se **saute** : le harnais reste utilisable sans accès aux secrets. + +Dès son premier passage, elle a trouvé un second trou : la voûte réelle de Technolibre +n'a ni `vault_nextcloud_admin` ni `vault_nextcloud_oidc`, que le plan exige depuis +l'arrivée de Nextcloud. Le déploiement aurait cassé sur une variable indéfinie. **Non +corrigé** : générer ces deux secrets est une décision, et le secret OIDC doit +correspondre à ce que Keycloak connaîtra. + ## 2026-08-03 (suite 12) — un pool Proxmox par tenant Onze des quatorze serveurs portent le **même nom court** chez Chezlepro et chez diff --git a/docs/audit/affirmations.md b/docs/audit/affirmations.md index be521af..5f6dfdd 100644 --- a/docs/audit/affirmations.md +++ b/docs/audit/affirmations.md @@ -318,7 +318,7 @@ les 🟡 relèvent de la **Phase 3 (arbitrage)**. Messages de tâche/rescousse/assert rendus génériques. **Validé** : `--syntax-check` OK, `ansible-lint` 0 échec sur le playbook, et **test fonctionnel** (mini-instance en clair) — le token est bien chargé depuis `all/vault.yml` et l'hôte depuis `proxmox.yml` - (assert vert). `proxmox.vault.yml` reste accepté en compatibilité. + (assert vert). **Suite, 2026-08-03** : `proxmox.vault.yml` n'est plus chargée du tout — tolérée, elle restait le seul porteur du jeton chez un tenant, dans un fichier gitignoré. Migrée vers `all/vault.yml`, supprimée, et `voute.py verifier` contrôle désormais la voûte RÉELLE quand elle est déchiffrable (ce que la seule vérification du gabarit ne pouvait pas voir). - **AFF-097 (🟡 → résolu, A)** — `lab/` codé en dur retiré des docs Proxmox/template : `playbooks/proxmox/README.md`, `docs/procedure-template-debian13-proxmox.md`, `docs/vm-lifecycle.md`, `docs/modeles_vm/debian13-proxmox.md` → `production/` + voûte diff --git a/docs/audit/preuve-2026-08-03.md b/docs/audit/preuve-2026-08-03.md index b568d46..7b6d3d5 100644 --- a/docs/audit/preuve-2026-08-03.md +++ b/docs/audit/preuve-2026-08-03.md @@ -30,7 +30,7 @@ | P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. | | P16 | Inventaire Ansible complet (--list) | AFF-030 | ⚪ SAUTE | Voute chiffree sans ANSIBLE_VAULT_PASSWORD_FILE (prerequis AFF-026). | | P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. | -| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 21 secret(s) exige(s), tous presents. | +| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 21 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) | | P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 27 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. | | P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. | | P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 2 instance(s) federee(s), aucun index en collision. | diff --git a/docs/config-proxmox.md b/docs/config-proxmox.md index b1ea205..419e95d 100644 --- a/docs/config-proxmox.md +++ b/docs/config-proxmox.md @@ -86,19 +86,27 @@ L'assistant demande « Configurer la voûte de secrets maintenant ». Si `oui` : - Ces secrets ne transitent **jamais** par le GUI ni par aucun fichier en clair ; le GUI n'en affiche que les **noms** (panneau « Intrants »). -### Migration depuis l'ancienne `proxmox.vault.yml` +### `proxmox.vault.yml` n'est plus lue (retirée le 2026-08-03) -Si tu as déjà une `proxmox.vault.yml`, déplace son contenu dans la voûte unique : +Les playbooks ne chargent plus ce fichier. Tolérée « en compatibilité », elle était +restée le **seul** porteur du jeton chez un tenant — et comme `*.vault.yml` est +gitignoré, ce jeton ne voyageait avec aucun dépôt. Une voûte unique qui ne l'était pas. + +Si tu en as encore une : ```bash -cd instance/inventories/production/group_vars -cp ../../../../exemples/vault.exemple.yml all/vault.yml +cd instance/inventories//group_vars ansible-vault view proxmox.vault.yml # relève token_id + secret -ansible-vault edit all/vault.yml # colle-les, renseigne le reste -ansible-vault encrypt all/vault.yml # si pas déjà chiffrée -git rm --cached --ignore-unmatch proxmox.vault.yml 2>/dev/null; rm proxmox.vault.yml proxmox.vault.yml.example +ansible-vault edit all/vault.yml # colle-les +rm proxmox.vault.yml proxmox.vault.yml.example +python3 ../../../../scripts/voute.py verifier # confirme : plus rien ne manque ``` +> **Le jeton se stocke tel que Proxmox l'affiche** (`utilisateur@realm!nom`). Les +> playbooks n'en gardent que la partie après `!` : `proxmoxer` recompose l'identifiant +> à partir d'`api_user`, et lui passer la forme complète produit un **401 muet** — +> alors que le même jeton fonctionne en `curl`. Les deux écritures sont acceptées. + --- ## Comment créer le token API côté Proxmox diff --git a/playbooks/proxmox/cloner_vm_debian.yml b/playbooks/proxmox/cloner_vm_debian.yml index 82a10cb..925dab4 100644 --- a/playbooks/proxmox/cloner_vm_debian.yml +++ b/playbooks/proxmox/cloner_vm_debian.yml @@ -9,10 +9,14 @@ # Config Proxmox de l'instance : on cherche dans l'inventaire de l'instance, # quel que soit son nom (lab > principal > production). Compatible « par instance ». setops_inventaires: [lab, principal, production] - # Voute Proxmox : la voute UNIFIEE de l'instance (all/vault.yml, ou `make config` - # ecrit le token) est autoritaire ; `proxmox.vault.yml` reste accepte en compat - # legacy. all/vault.yml en dernier => il l'emporte s'il definit les memes cles. - setops_gv_proxmox: [proxmox.yml, proxmox.vault.yml, proxmox.local.yml, all/vault.yml] + # Voute UNIQUE de l'instance : all/vault.yml porte TOUS les secrets, jeton + # d'hyperviseur compris (D-19). `proxmox.vault.yml` a ete retire le 2026-08-03 : + # tolere "en compatibilite", il restait le SEUL porteur du jeton chez un tenant, + # et il etait gitignore — une voute unique qui ne l'etait pas, dans un fichier + # qui ne voyageait avec aucun depot. P18 ne pouvait pas le voir : il verifie le + # gabarit, pas le contenu reel. + # `proxmox.local.yml` reste une surcharge locale volontaire, non versionnee. + setops_gv_proxmox: [proxmox.yml, proxmox.local.yml, all/vault.yml] # Le CLUSTER appartient a l'HEBERGEUR (API, noeuds, stockages, ponts). Son fichier # vit dans le depot de l'hebergeur, que le symlink `underlay.yml` designe deja # (D-14/D-17) : on en DERIVE le chemin plutot que de redeclarer l'hebergeur. Absent @@ -68,7 +72,13 @@ ansible.builtin.set_fact: proxmox_api_host_effectif: "{{ proxmox_api_host | default(lookup('env', 'PROXMOX_API_HOST'), true) }}" proxmox_api_user_effectif: "{{ proxmox_api_user | default(lookup('env', 'PROXMOX_API_USER'), true) }}" - proxmox_api_token_id_effectif: "{{ proxmox_api_token_id | default(lookup('env', 'PROXMOX_API_TOKEN_ID'), true) }}" + # `split('!')[-1]` : proxmoxer recompose lui-meme `utilisateur!nom` a partir de + # `api_user` et `api_token_id`. Lui passer la forme COMPLETE produit + # `ansible@pve!ansible@pve!nom` et un 401 muet — le meme jeton fonctionne en + # HTTP direct, ce qui rend le diagnostic trompeur. Verifie contre le cluster le + # 2026-08-03 : forme complete = 401, forme courte = OK. Sans effet si la voute + # stocke deja le nom seul, donc les deux ecritures restent acceptees. + proxmox_api_token_id_effectif: "{{ (proxmox_api_token_id | default(lookup('env', 'PROXMOX_API_TOKEN_ID'), true)) | string | split('!') | last }}" proxmox_api_token_secret_effectif: "{{ proxmox_api_token_secret | default(lookup('env', 'PROXMOX_API_TOKEN_SECRET'), true) }}" proxmox_api_port_effectif: "{{ proxmox_api_port | default(lookup('env', 'PROXMOX_API_PORT'), true) }}" no_log: true diff --git a/playbooks/proxmox/supprimer_vm_debian.yml b/playbooks/proxmox/supprimer_vm_debian.yml index 453e103..e648f54 100644 --- a/playbooks/proxmox/supprimer_vm_debian.yml +++ b/playbooks/proxmox/supprimer_vm_debian.yml @@ -10,10 +10,14 @@ vars: setops_inventaires: [lab, principal, production] - # Voute Proxmox : la voute UNIQUE de l'instance (all/vault.yml) est autoritaire ; - # `proxmox.vault.yml` reste accepte en compat legacy pour les instances qui en - # ont encore une. all/vault.yml en dernier => il l'emporte sur les memes cles. - setops_gv_proxmox: [proxmox.yml, proxmox.vault.yml, proxmox.local.yml, all/vault.yml] + # Voute UNIQUE de l'instance : all/vault.yml porte TOUS les secrets, jeton + # d'hyperviseur compris (D-19). `proxmox.vault.yml` a ete retire le 2026-08-03 : + # tolere "en compatibilite", il restait le SEUL porteur du jeton chez un tenant, + # et il etait gitignore — une voute unique qui ne l'etait pas, dans un fichier + # qui ne voyageait avec aucun depot. P18 ne pouvait pas le voir : il verifie le + # gabarit, pas le contenu reel. + # `proxmox.local.yml` reste une surcharge locale volontaire, non versionnee. + setops_gv_proxmox: [proxmox.yml, proxmox.local.yml, all/vault.yml] # Le CLUSTER appartient a l'HEBERGEUR (API, noeuds, stockages, ponts). Son fichier # vit dans le depot de l'hebergeur, que le symlink `underlay.yml` designe deja # (D-14/D-17) : on en DERIVE le chemin plutot que de redeclarer l'hebergeur. Absent @@ -63,7 +67,10 @@ api_host: "{{ proxmox_api_host | default(lookup('env', 'PROXMOX_API_HOST'), true) }}" api_port: "{{ proxmox_api_port | default('8006', true) }}" api_user: "{{ proxmox_api_user | default(lookup('env', 'PROXMOX_API_USER'), true) }}" - api_tid: "{{ proxmox_api_token_id | default(lookup('env', 'PROXMOX_API_TOKEN_ID'), true) }}" + # `split('!')[-1]` : proxmoxer recompose `utilisateur!nom` a partir d'api_user + # et d'api_token_id. La forme COMPLETE donne un 401 muet (verifie contre le + # cluster le 2026-08-03). Sans effet si la voute stocke deja le nom seul. + api_tid: "{{ (proxmox_api_token_id | default(lookup('env', 'PROXMOX_API_TOKEN_ID'), true)) | string | split('!') | last }}" api_secret: "{{ proxmox_api_token_secret | default(lookup('env', 'PROXMOX_API_TOKEN_SECRET'), true) }}" no_log: true diff --git a/scripts/devis_proxmox_pools.py b/scripts/devis_proxmox_pools.py index 54d2ffd..5542686 100644 --- a/scripts/devis_proxmox_pools.py +++ b/scripts/devis_proxmox_pools.py @@ -119,6 +119,8 @@ def rendre(devis: dict) -> str: out += [ f"## {b['tenant']} — pool `{b['pool']}` (index {b['index']})", f"# {len(b['membres'])} VM au plan, dont {len(actifs)} active(s).", + "# Nom derive du seed, comme tout le reste. Distinct des pools " + "`Env.Tenant` anterieurs a Set-OPS, qu'on ne touche pas.", "", "### 1. Creer le pool (sans effet s'il existe)", f"pvesh create /pools --poolid {b['pool']} \\", diff --git a/scripts/inventory_rules.py b/scripts/inventory_rules.py index 8f08a33..0238fb4 100644 --- a/scripts/inventory_rules.py +++ b/scripts/inventory_rules.py @@ -95,11 +95,18 @@ def integrations_de(srv: dict, services_hote: set[str] | None = None, def pool_de(nom_instance: str, index: int) -> str: """Nom du pool Proxmox d'un tenant. DERIVE, jamais saisi. - Onze des quatorze serveurs portent le meme nom court chez deux tenants : c'est - la preuve que la nomenclature est un vrai gabarit, et il ne faut pas la casser. - Mais la console Proxmox, elle, affiche le NOM — deux `infra-pki-01` y sont - indiscernables a l'oeil, alors que tout le reste (IP, VMID, VLAN, FQDN) differe. - Le pool restitue l'appartenance la ou elle manquait : dans l'arbre du cluster. + Pourquoi le pool. Onze des quatorze serveurs portent le meme nom court chez deux + tenants : c'est la preuve que la nomenclature est un vrai gabarit, et il ne faut + pas la casser. Mais la console Proxmox affiche le NOM — deux `infra-pki-01` y + sont indiscernables a l'oeil, alors que tout le reste (IP, VMID, VLAN, FQDN) + differe. Le pool restitue l'appartenance sans rien renommer. + + Pourquoi DERIVE malgre les pools existants. Le cluster porte deja une convention + `Env.Tenant` (`Prod.Chezlepro`, `Lab.KBR`...) qui regroupe des VM anterieures a + Set-OPS — ZIMBRA, erplibre, eregion... Ces pools-la sont l'ANCIEN MONDE : on n'y + touche pas, et on n'y verse pas la flotte generee. Melanger les deux effacerait + justement la frontiere que Set-OPS etablit. La flotte derivee a donc ses propres + pools, nommes comme tout le reste : depuis le seed. `index` est deja garanti unique dans la federation par P21 : le nom l'est donc aussi, sans registre supplementaire. Forme : `Chezlepro-17`, `Technolibre-11`. diff --git a/scripts/voute.py b/scripts/voute.py index 946fa0d..c3431e9 100644 --- a/scripts/voute.py +++ b/scripts/voute.py @@ -116,6 +116,44 @@ def cles_gabarit(path: Path) -> set[str]: return set(data) if isinstance(data, dict) else set() +def cles_voute_reelle() -> set[str] | None: + """Noms des cles de la VRAIE voute, si elle est dechiffrable ici. Sinon None. + + Le gabarit dit ce qu'il FAUDRAIT ; il ne dit pas ce qui EST. Un tenant a vecu + avec son jeton d'hyperviseur dans `proxmox.vault.yml` — un fichier gitignore, + hors de la voute unique — pendant que le gabarit affichait « complet ». La + verification du gabarit ne pouvait pas le voir. + + On ne lit QUE les noms de cles ; aucune valeur n'est retournee ni affichee. + None (et non un ensemble vide) quand la voute est absente ou le mot de passe + indisponible : c'est une verification qui se SAUTE, jamais une qui echoue — + le harnais doit rester utilisable sans acces aux secrets. + """ + chemin = None + for nom in ("principal", "production", "lab"): + p = INSTANCE / "inventories" / nom / "group_vars" / "all" / "vault.yml" + if p.is_file(): + chemin = p + break + if chemin is None: + return None + brut = chemin.read_bytes() + if not brut.startswith(b"$ANSIBLE_VAULT"): + data = yaml.safe_load(brut.decode("utf-8", "ignore")) or {} + return set(data) if isinstance(data, dict) else set() + mdp_fichier = os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE") + if not mdp_fichier or not Path(os.path.expanduser(mdp_fichier)).is_file(): + return None + try: + from ansible.parsing.vault import VaultLib, VaultSecret + mdp = Path(os.path.expanduser(mdp_fichier)).read_bytes().strip() + clair = VaultLib([("default", VaultSecret(mdp))]).decrypt(brut) + data = yaml.safe_load(clair.decode("utf-8")) or {} + return set(data) if isinstance(data, dict) else set() + except Exception: + return None + + def main() -> int: parser = argparse.ArgumentParser(description="Couverture du gabarit de voute Set-OPS.") sub = parser.add_subparsers(dest="commande", required=True) @@ -153,9 +191,27 @@ def main() -> int: print(f"{'erreur' if args.strict else 'note'}: {len(inutiles)} cle(s) du gabarit " f"que le plan n'exige plus : {', '.join(inutiles)}", file=flux) - if manquants or (inutiles and args.strict): + # Deuxieme volet : la voute REELLE, quand elle est lisible ici. Le gabarit dit + # ce qu'il faudrait ; lui seul ne prouve rien sur ce qui est reellement en place. + reelles = cles_voute_reelle() + absentes_reelles: list[str] = [] + if reelles is not None: + attendues = set(exiges) | SECRETS_HORS_MOTIF + absentes_reelles = sorted(attendues - reelles) + if absentes_reelles: + print(f"erreur: {len(absentes_reelles)} secret(s) absent(s) de la VOUTE REELLE " + f"(group_vars/all/vault.yml) : {', '.join(absentes_reelles)}", file=sys.stderr) + print(" Un secret servi depuis un autre fichier (proxmox.vault.yml, " + "proxmox.local.yml) n'est pas dans la voute unique — cf. D-19.", file=sys.stderr) + + if manquants or absentes_reelles or (inutiles and args.strict): return 2 - print(f"Gabarit de voute complet : {len(exiges)} secret(s) exige(s), tous presents.") + if reelles is None: + print(f"Gabarit de voute complet : {len(exiges)} secret(s) exige(s), tous presents. " + f"(Voute reelle non lisible ici : verification sautee.)") + else: + print(f"Gabarit de voute complet : {len(exiges)} secret(s) exige(s), tous presents. " + f"Voute reelle : {len(reelles)} cle(s), aucun manque.") return 0