proxmox : pools créés, jeton normalisé, reliquat de voûte supprimé

Reconnaissance en lecture seule de l'API du cluster, avec le jeton de la voûte.
Trois valeurs devinées étaient fausses, et deux défauts bloquants sont apparus.

Corrigé d'après le cluster
- stockages : truenas-dbsql manquait ; le catalogue ne garde que ceux qui
  portent `images` (PBS, cephFS, local et truenas iSCSI n'accueillent pas de
  disque de VM) ;
- ponts : vmbr0 avait été omis, et l'uniformité sur les trois nœuds n'avait pas
  été vérifiée — un pont partiel empêche la VM de démarrer sur certains nœuds.

Pools
Chezlepro-17 et Technolibre-11 créés, 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. Diff réel : 2 pools ajoutés,
0 retiré, 1 VM sur 38 déplacée — infra-pki-01, qui n'avait 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ù
un 401 muet — alors que le même jeton fonctionne en curl. Mesuré des deux côtés
avec un module en lecture seule : forme complète = 401, forme courte = OK.
Normalisation par split('!') | last, qui accepte les deux écritures.

Reliquat proxmox.vault.yml 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, avec relecture et aller-retour de chiffrement vérifiés avant écriture ;
fichier supprimé, listes de chargement des playbooks nettoyées, validé par un
appel API réel ne chargeant que all/vault.yml.

La garde qui manquait
voute.py verifier ne comparait que le gabarit — il disait « complet » pendant
qu'un secret vivait ailleurs. 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, et vérification sautée sans mot de passe.

Elle a trouvé un second trou dès son premier passage : la voûte réelle de
Technolibre n'a ni vault_nextcloud_admin ni vault_nextcloud_oidc, que le plan
exige. Non corrigé — générer ces secrets est une décision, et celui d'OIDC doit
correspondre à ce que Keycloak connaîtra.

27 preuves OK. --syntax-check et ansible-lint (production) sur les playbooks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-03 15:52:44 -04:00
parent 60a60b6fb1
commit 4dd3d46412
9 changed files with 182 additions and 26 deletions

View file

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

View file

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

View file

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

View file

@ -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/<env>/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

View file

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

View file

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

View file

@ -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']} \\",

View file

@ -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`.

View file

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