diff --git a/Makefile b/Makefile index 9755709..7de4a87 100644 --- a/Makefile +++ b/Makefile @@ -836,6 +836,9 @@ cloner-vm: ansible-runtime ## Clone une VM depuis le gabarit dore — HOTE= [[ -f "$$f" ]] && vault_file="$$f" && break 2; \ done; \ done; \ + if [[ -z "$$vault_file" && -L underlay.yml ]]; then \ + vault_file="$$(dirname "$$(readlink -f underlay.yml)")/underlay.vault.yml"; \ + fi; \ if [[ -n "$$vault_file" && -f "$$vault_file" ]]; then \ read -r premiere_ligne < "$$vault_file" || true; \ case "$$premiere_ligne" in \ diff --git a/playbooks/proxmox/cloner_vm_debian.yml b/playbooks/proxmox/cloner_vm_debian.yml index 94c757f..7bd788d 100644 --- a/playbooks/proxmox/cloner_vm_debian.yml +++ b/playbooks/proxmox/cloner_vm_debian.yml @@ -22,6 +22,12 @@ # (D-14/D-17) : on en DERIVE le chemin plutot que de redeclarer l'hebergeur. Absent # (depot sans underlay monte), le `stat` echoue simplement et rien n'est charge. setops_proxmox_hebergeur: "{{ (playbook_dir ~ '/../../underlay.yml') | realpath | dirname }}/proxmox-hebergeur.yml" + # LA VOUTE DE L'UNDERLAY — les secrets du MONDE PHYSIQUE (jeton d'API du cluster). + # Meme derivation que le fichier ci-dessus : le symlink `underlay.yml` designe deja + # l'hebergeur, on n'a rien a redeclarer. Depuis le 2026-08-22 ces secrets ne vivent + # plus dans la voute d'un tenant : recopies chez chacun, ils ne pouvaient plus etre + # revoques isolement (docs/frontiere-physique-virtuel.md). + setops_voute_underlay: "{{ (playbook_dir ~ '/../../underlay.yml') | realpath | dirname }}/underlay.vault.yml" tasks: - name: Verifier les fichiers de variables Proxmox optionnels @@ -50,6 +56,11 @@ file: "{{ setops_proxmox_hebergeur }}" when: proxmox_fichier_hebergeur.stat.exists + - name: Verifier la voute de l'underlay + ansible.builtin.stat: + path: "{{ setops_voute_underlay }}" + register: proxmox_voute_underlay + - name: Charger les secrets Proxmox Vault block: - name: Charger les secrets de la voute Proxmox @@ -60,6 +71,15 @@ - item.stat.exists - item.stat.path is search('vault') no_log: true + + # APRES celle du tenant, donc elle GAGNE : quand les deux portent la cle, c'est + # celle de l'hebergeur qui fait foi. C'est le sens de la frontiere entre les deux + # mondes, et ca rend la migration d'un site sans coupure. + - name: Charger les secrets du monde physique (voute de l'underlay) + ansible.builtin.include_vars: + file: "{{ setops_voute_underlay }}" + when: proxmox_voute_underlay.stat.exists + no_log: true rescue: - name: Expliquer l echec de chargement du Vault ansible.builtin.fail: diff --git a/scripts/appliquer_opnsense.py b/scripts/appliquer_opnsense.py index 54bbd38..aca5585 100644 --- a/scripts/appliquer_opnsense.py +++ b/scripts/appliquer_opnsense.py @@ -39,21 +39,19 @@ RACINE = Path(__file__).resolve().parent.parent sys.path.insert(0, str(RACINE / "scripts")) import devis_opnsense as devis_mod # noqa: E402 +from proxmox_api import voute as voute_proxmox # noqa: E402 def _voute(base: Path) -> dict: - """Secrets d'API de la frontiere, lus par ansible-vault. Rien n'est journalise.""" - for nom in ("principal", "production", "lab"): - p = base / "inventories" / nom / "group_vars" / "all" / "vault.yml" - if p.is_file(): - r = subprocess.run(["ansible-vault", "view", str(p)], - capture_output=True, text=True) - if r.returncode != 0: - raise SystemExit( - "Voute illisible : renseigner ANSIBLE_VAULT_PASSWORD_FILE.\n" - + r.stderr.strip()[:300]) - return yaml.safe_load(r.stdout) or {} - raise SystemExit(f"Aucune voute trouvee sous {base}/inventories/*/group_vars/all/.") + """Secrets d'API de la frontiere — SOURCE UNIQUE, partagee avec l'acces au cluster. + + La frontiere appartient au MONDE PHYSIQUE : sa cle d'API vit dans la voute de + l'underlay, chez l'hebergeur, pas chez un locataire (2026-08-22, cf. + docs/frontiere-physique-virtuel.md). `proxmox_api.voute` porte deja cette regle et son + repli sur la voute du tenant pendant la migration : la reecrire ici aurait fait une + dixieme copie le jour meme ou l'on refermait les neuf autres. + """ + return voute_proxmox(base) class Frontiere: diff --git a/scripts/proxmox_api.py b/scripts/proxmox_api.py index b4f68bc..bac2373 100644 --- a/scripts/proxmox_api.py +++ b/scripts/proxmox_api.py @@ -30,17 +30,52 @@ def hebergeur() -> Path: return lien.resolve().parent +def _lire_voute(p: Path) -> dict: + r = subprocess.run(["ansible-vault", "view", str(p)], capture_output=True, text=True) + if r.returncode != 0: + raise SystemExit("Voute illisible : renseigner ANSIBLE_VAULT_PASSWORD_FILE.\n" + + r.stderr.strip()[:300]) + return yaml.safe_load(r.stdout) or {} + + +def voute_underlay(base: Path) -> dict: + """Les secrets du MONDE PHYSIQUE : `underlay.vault.yml`, chez l'hebergeur. + + LA VOUTE DE L'UNDERLAY EST DISTINCTE DE CELLE DES TENANTS (2026-08-22). Le jeton d'API + du cluster et la cle de la frontiere appartiennent a l'hebergeur, pas a l'organisation + hebergee. Recopies dans la voute de chaque tenant, ils ne pouvaient plus etre revoques + isolement : retirer l'acces d'un locataire revenait a le retirer a tous. + + C'est la faute des neuf copies de la resolution d'instance, appliquee aux secrets. On + ferme la meme porte, du meme geste : une source, pas N copies. + + Rend {} si le fichier n'existe pas — l'appelant retombe alors sur la voute du tenant, + le temps que les sites migrent. + """ + p = base / "underlay.vault.yml" + return _lire_voute(p) if p.is_file() else {} + + def voute(base: Path) -> dict: - """Secrets, lus par ansible-vault. Rien n'est journalise ni ecrit sur disque.""" + """Secrets, lus par ansible-vault. Rien n'est journalise ni ecrit sur disque. + + L'ordre compte : la voute de l'UNDERLAY d'abord (monde physique), celle du tenant + ensuite. Un site migre est donc lu correctement, un site qui ne l'est pas encore + continue de fonctionner — et le jour ou les deux portent la cle, c'est celle de + l'hebergeur qui fait foi, ce qui est le sens de la frontiere. + """ + fusion: dict = {} for nom in ("principal", "production", "lab"): p = base / "inventories" / nom / "group_vars" / "all" / "vault.yml" if p.is_file(): - r = subprocess.run(["ansible-vault", "view", str(p)], capture_output=True, text=True) - if r.returncode != 0: - raise SystemExit("Voute illisible : renseigner ANSIBLE_VAULT_PASSWORD_FILE.\n" - + r.stderr.strip()[:300]) - return yaml.safe_load(r.stdout) or {} - raise SystemExit(f"Aucune voute sous {base}/inventories/*/group_vars/all/.") + fusion = _lire_voute(p) + break + physique = voute_underlay(base) + if not fusion and not physique: + raise SystemExit(f"Aucune voute sous {base} (ni underlay.vault.yml, " + f"ni inventories/*/group_vars/all/vault.yml).") + fusion.update(physique) + return fusion class Cluster: diff --git a/scripts/voute.py b/scripts/voute.py index 2f81a4e..bf20606 100644 --- a/scripts/voute.py +++ b/scripts/voute.py @@ -39,8 +39,13 @@ INSTANCE = instance_courante() ROLES = RACINE / "roles" MOTIF_VAULT = re.compile(r"\b(vault_[a-z0-9_]+)") -# Secrets d'acces a l'hyperviseur : attendus au gabarit, mais hors motif vault_*. -SECRETS_HORS_MOTIF = {"proxmox_api_token_id", "proxmox_api_token_secret"} +# Secrets du MONDE PHYSIQUE : ils n'ont pas le prefixe `vault_`, et depuis le 2026-08-22 +# ils ne vivent PLUS dans la voute d'un tenant mais dans celle de l'underlay, chez +# l'hebergeur (docs/frontiere-physique-virtuel.md). Un tenant qui les porte encore est un +# reste de migration, pas une exigence — d'ou leur retrait des attentes du gabarit. +SECRETS_MONDE_PHYSIQUE = {"proxmox_api_token_id", "proxmox_api_token_secret", + "vault_opnsense_api_key", "vault_opnsense_api_secret"} +SECRETS_HORS_MOTIF: set[str] = set() def _inventaire_dir() -> Path | None: @@ -284,7 +289,8 @@ def main() -> int: return 0 manquants = sorted(set(exiges) - presentes) - inutiles = sorted(presentes - set(exiges) - SECRETS_HORS_MOTIF) + inutiles = sorted(presentes - set(exiges) - SECRETS_HORS_MOTIF + - SECRETS_MONDE_PHYSIQUE) if manquants: print(f"erreur: {len(manquants)} secret(s) exige(s) par le plan mais ABSENT(s) " @@ -302,7 +308,7 @@ def main() -> int: reelles = cles_voute_reelle() absentes_reelles: list[str] = [] if reelles is not None: - attendues = set(exiges) | SECRETS_HORS_MOTIF + attendues = (set(exiges) | SECRETS_HORS_MOTIF) - SECRETS_MONDE_PHYSIQUE absentes_reelles = sorted(attendues - reelles) if absentes_reelles: print(f"erreur: {len(absentes_reelles)} secret(s) absent(s) de la VOUTE REELLE "