From 810670cd30870564430c8188166f2fe1f3d7a1d7 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Tue, 13 Oct 2026 23:46:46 -0400 Subject: [PATCH] site : le premier site construit depuis zero, et ce que le moteur supposait deja en place - cloner_vm_debian / supprimer_vm_debian suivent SETOPS_UNDERLAY (le clonage visait le cluster du lien underlay.yml) ; sept cibles du Makefile aussi pour la voute. - site_machines.py transmet au clone la cle, le domaine et le resolveur du plan du site. - La machine qui porte le cache recoit artefacts_amorcage vide ; le socle retire alors le proxy herite du gabarit. P79 n'admet ce vide que sur la machine qui porte le cache. - serveur_icingaweb2 : la base et le compte d'amorcage apres l'installation des paquets. - genome_pousser : cle du runner, organisation genome et depots crees s'ils manquent. - common_packages : la lecture apt-daily s'execute en simulation (check_mode: false). 95 preuves (make verifier), make test passe. CHANGELOG 146. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 45 ++++++ Makefile | 15 +- playbooks/groupes/serveur_debian.yml | 12 ++ playbooks/maintenance/genome_pousser.yml | 81 ++++++++++ playbooks/proxmox/cloner_vm_debian.yml | 8 +- playbooks/proxmox/supprimer_vm_debian.yml | 6 +- roles/common_packages/tasks/minuteurs.yml | 4 + roles/serveur_icingaweb2/tasks/main.yml | 171 +++++++++++----------- scripts/prouver.py | 15 +- scripts/site_inventaire.py | 10 ++ scripts/site_machines.py | 18 +++ 11 files changed, 290 insertions(+), 95 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37b27d4..eb7903f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,48 @@ +## 2026-10-13 (146) — Le premier site construit depuis zéro : ce que le moteur supposait déjà là + +**95 preuves.** `make verifier` passe (site de Chezlepro), `make test` passe. Témoins : l'inventaire +du site de Chezlepro ne change que pour sa propre machine de cache ; P79 signale toujours une +valeur d'amorçage vide sur une machine qui ne porte pas le cache, et une valeur absente sur celle +qui le porte. + +Les neuf machines du site de TechnoLibre sont nées du gabarit 9006 (`maurice`, `nvme` et +`miroir`, ~30 s chacune) et le site se déploie couche par couche. Aucun site n'avait jamais été +construit depuis zéro — celui de Chezlepro ne l'a jamais été — et chaque arrêt a montré une +chose que le moteur supposait **déjà en place** : + +- **Le clonage visait le cluster du lien `underlay.yml`**, pas celui de `SETOPS_UNDERLAY` : + les VM de TechnoLibre seraient nées chez Chezlepro, avec ses jetons. `cloner_vm_debian` et + `supprimer_vm_debian` suivent la variable ; **sept cibles** du Makefile aussi pour la voûte + (`site-appliquer`, `site-deployer-tout`, `genome-pousser`, `genome-etat`, `forge-cles`, + `forge-comptes`, `cloner-vm`). +- **`site-creer` ne transmettait ni la clé, ni le domaine, ni le résolveur du plan** : les VM + ne portaient que la clé du gabarit (celle de Chezlepro), fermées à Ansible. `site_machines.py` + les tire du plan, et refuse si la clé publique manque. +- **Le cache s'amorçait sur lui-même**, et toutes les machines sur un cache déployé plus tard. + La machine qui porte le cache reçoit `artefacts_amorcage` vide ; le socle retire alors le + fichier hérité du gabarit (qui visait `10.0.33.21`, une adresse d'avant le renumérotage) et + elle va droit chez Debian. Déployée seule d'abord ; prouvée : HTTP 200 depuis une autre zone. +- **Les règles nftables du site n'avaient jamais été générées** pour ce site : le pare-feu + serait retombé sur son gabarit générique. Générées, relues, `nft -c` sur deux machines. +- **La PKI voulait restaurer son état d'un dépôt qui n'existait pas encore.** Le refus est + juste (conclure « rien à restaurer » faute de joindre le dépôt ferait naître neuf ce qui + attend ailleurs) : le dépôt de sauvegarde est déployé d'abord, il répond « rien + d'antérieur », et l'autorité naît neuve parce qu'il l'a dit. L'ordre des couches reste à + corriger pour une première naissance. +- **`serveur_icingaweb2` chargeait son schéma avant d'installer le paquet qui le fournit.** + Le bloc base et compte d'amorçage vient après les paquets. +- **Une forge neuve n'avait ni organisation ni dépôts**, et le runner ne fabriquait sa clé + qu'après avoir cloné. `genome_pousser` crée la clé si elle manque (à l'identique du rôle), + l'organisation `genome` (publique) et les dépôts manquants (moteur et modèles publics, + carte du site et plans privés) — la forme de la forge de Chezlepro, faite à la main. +- **La simulation échouait sur une lecture** (`systemctl is-active` sautée en `--check`, donc + sans `rc`) : `check_mode: false`. + +**Voûte du site de TechnoLibre** : les secrets TSIG du DNS public, identiques à ceux du site de +Chezlepro (les primaires n'en connaissent qu'un par relation). **Plan** : la clé d'hôte de la +forge, relevée à la source. Un lien entre les deux sites est prévu ; d'ici là, le DNS public +de ce site ne reçoit pas les zones. + ## 2026-10-13 (145) — La face publique d'un site sans adresse fixe : le relais **95 preuves.** `make verifier` passe (site de Chezlepro), `make test` passe. Témoin : le devis de diff --git a/Makefile b/Makefile index cbc3503..07fe982 100644 --- a/Makefile +++ b/Makefile @@ -1315,7 +1315,7 @@ site-deployer-tout: ansible-runtime ## Deploie TOUT le site dans l'ordre des cou python3 scripts/verifier_genome_a_jour.py || exit 2; \ python3 scripts/orchestrer.py verifier; \ python3 scripts/orchestrer.py ecrire; \ - voute="$$(dirname "$$(readlink -f underlay.yml)")/underlay.vault.yml"; \ + voute="$$(dirname "$$(readlink -f "$${SETOPS_UNDERLAY:-underlay.yml}")")/underlay.vault.yml"; \ supp=(); [[ -f "$$voute" ]] && supp+=( -e "@$$voute" ); \ mode="$$([[ -n "$(MODE_CHECK)" ]] && printf -- '--check --diff' || true)"; \ ansible-playbook -i scripts/site_inventaire.py playbooks/site.yml \ @@ -1371,7 +1371,7 @@ gabarit-etat: ## Le gabarit porte-t-il ce que le SITE declare ? — ne corrige r .PHONY: genome-etat genome-etat: ## Ce que la forge du site porte, face au poste — ne corrige rien, regarde @set -e; \ - voute="$$(dirname "$$(readlink -f underlay.yml)")/underlay.vault.yml"; \ + voute="$$(dirname "$$(readlink -f "$${SETOPS_UNDERLAY:-underlay.yml}")")/underlay.vault.yml"; \ supp=(); [[ -f "$$voute" ]] && supp+=( -e "@$$voute" ); \ ansible-playbook -i scripts/site_inventaire.py \ playbooks/maintenance/genome_etat.yml "$${supp[@]}" $(ARGS) @@ -1386,7 +1386,7 @@ publier: ## Publie le genome d'un geste : eregion PUIS la forge du site, et le v .PHONY: publier genome-pousser: ## Le runner du site pousse le genome sur la forge du site — DEPOT= pour un seul @set -e; \ - voute="$$(dirname "$$(readlink -f underlay.yml)")/underlay.vault.yml"; \ + voute="$$(dirname "$$(readlink -f "$${SETOPS_UNDERLAY:-underlay.yml}")")/underlay.vault.yml"; \ supp=(); [[ -f "$$voute" ]] && supp+=( -e "@$$voute" ); \ SETOPS_DEPOT="$(DEPOT)" ansible-playbook -i scripts/site_inventaire.py \ playbooks/maintenance/genome_pousser.yml "$${supp[@]}" $(ARGS) @@ -1394,7 +1394,7 @@ genome-pousser: ## Le runner du site pousse le genome sur la forge du site — D .PHONY: forge-cles forge-cles: ## Le site enregistre sur le depot de chaque locataire la cle de lecture que sa face publie — CONFIRMER=true pour ecrire @set -e; \ - voute="$$(dirname "$$(readlink -f underlay.yml)")/underlay.vault.yml"; \ + voute="$$(dirname "$$(readlink -f "$${SETOPS_UNDERLAY:-underlay.yml}")")/underlay.vault.yml"; \ supp=(); [[ -f "$$voute" ]] && supp+=( -e "@$$voute" ); \ CONFIRMER="$(CONFIRMER)" ansible-playbook -i scripts/site_inventaire.py \ playbooks/maintenance/forge_cles.yml "$${supp[@]}" $(ARGS) @@ -1402,7 +1402,7 @@ forge-cles: ## Le site enregistre sur le depot de chaque locataire la cle de lec .PHONY: forge-comptes forge-comptes: ## Les comptes de la forge du site : un par mainteneur (plan du site), et le lecteur de la console — CONFIRMER=true pour ecrire @set -e; \ - voute="$$(dirname "$$(readlink -f underlay.yml)")/underlay.vault.yml"; \ + voute="$$(dirname "$$(readlink -f "$${SETOPS_UNDERLAY:-underlay.yml}")")/underlay.vault.yml"; \ supp=(); [[ -f "$$voute" ]] && supp+=( -e "@$$voute" ); \ CONFIRMER="$(CONFIRMER)" ansible-playbook -i scripts/site_inventaire.py \ playbooks/maintenance/forge_comptes.yml "$${supp[@]}" $(ARGS) @@ -1482,6 +1482,9 @@ site-creer: ## Cree les machines du site depuis l'underlay — CONFIRMER=true NOEUD_PROXMOX="$$SETOPS_NOEUD" \ VMID_MODELE="$${SETOPS_VMID_MODELE:-$(VMID_MODELE)}" \ CLONE_COMPLET="$$SETOPS_CLONE_COMPLET" \ + CLE_SSH_PUBLIQUE="$${SETOPS_CLE_PUBLIQUE:-$(CLE_SSH_PUBLIQUE)}" \ + DNS="$${SETOPS_DNS:-$(DNS)}" \ + DOMAINE_RECHERCHE="$${SETOPS_DOMAINE:-$(DOMAINE_RECHERCHE)}" \ FORMAT_DISQUE="$(FORMAT_DISQUE)"; \ printf '=== %s : %s s ===\n' "$$m" "$$(( $$(date +%s) - t0 ))"; \ done; \ @@ -1711,7 +1714,7 @@ cloner-vm: ansible-runtime ## Clone une VM depuis le gabarit dore — HOTE= done; \ done; \ if [[ -z "$$vault_file" && -L underlay.yml ]]; then \ - vault_file="$$(dirname "$$(readlink -f underlay.yml)")/underlay.vault.yml"; \ + vault_file="$$(dirname "$$(readlink -f "$${SETOPS_UNDERLAY:-underlay.yml}")")/underlay.vault.yml"; \ fi; \ if [[ -n "$$vault_file" && -f "$$vault_file" ]]; then \ read -r premiere_ligne < "$$vault_file" || true; \ diff --git a/playbooks/groupes/serveur_debian.yml b/playbooks/groupes/serveur_debian.yml index 45a1307..cd2dbfa 100644 --- a/playbooks/groupes/serveur_debian.yml +++ b/playbooks/groupes/serveur_debian.yml @@ -79,6 +79,18 @@ - artefacts_amorcage is defined - artefacts_amorcage | string | length > 0 + # LA MACHINE QUI PORTE LE CACHE VA DROIT CHEZ DEBIAN (2026-10-13). L'inventaire lui + # donne `artefacts_amorcage` VIDE (et non absent) : le fichier herite du gabarit vise + # un cache qui n'existe pas encore — le sien — ou une adresse perimee. Absent, il + # survivrait ; vide, on le retire. + - name: Retirer l'amorcage sur la machine qui porte le cache + ansible.builtin.file: + path: /etc/apt/apt.conf.d/00-setops-amorcage-artefacts + state: absent + when: + - artefacts_amorcage is defined + - artefacts_amorcage | string | length == 0 + # RETIRER CE QUI DESIGNE UN CACHE QUI N'EXISTE PLUS (2026-09-10). # # `client_artefacts` ecrit `00-setops-artefacts`, qui trie APRES le plancher et gagne. diff --git a/playbooks/maintenance/genome_pousser.yml b/playbooks/maintenance/genome_pousser.yml index b5ab859..f0d0b1b 100644 --- a/playbooks/maintenance/genome_pousser.yml +++ b/playbooks/maintenance/genome_pousser.yml @@ -95,6 +95,87 @@ # depot par depot, et l'exploitant doit aller cliquer. La cible s'en charge : la # declaration est idempotente (422 = deja la), et sa portee est exactement ce qu'on # pousse, jamais plus large. + # UNE FORGE NEUVE, UN RUNNER NEUF (2026-10-13, premier site construit depuis zero). Le + # runner du site ne fabrique sa cle qu'APRES avoir clone le genome, et il ne peut cloner + # que ce que cette commande a pousse — qui exige sa cle. Personne n'allait au bout. On + # fabrique donc la cle ici si elle manque, A L'IDENTIQUE de `serveur_ops` (meme commande, + # meme commentaire, `creates:`) : le role la retrouvera et ne la refera pas. + - name: Assurer le répertoire SSH du runner + ansible.builtin.file: + path: "{{ genome_racine }}/.ssh" + state: directory + mode: "0700" + + - name: Fabriquer la clé du runner si elle n'existe pas encore + ansible.builtin.command: + cmd: >- + ssh-keygen -t {{ serveur_ops_cle_type | default('ed25519') }} -N '' + -C "setops@{{ inventory_hostname }}.{{ domaine_interne }}" + -f {{ genome_racine }}/.ssh/id_{{ serveur_ops_cle_type | default('ed25519') }} + creates: "{{ genome_racine }}/.ssh/id_{{ serveur_ops_cle_type | default('ed25519') }}" + + # L'ORGANISATION ET SES DEPOTS N'ETAIENT CREES PAR PERSONNE : au site de Chezlepro, une + # fois, a la main. Meme forme qu'elle : organisation PUBLIQUE ; le moteur et les modeles + # PUBLICS (le runner les clone en anonyme) ; la carte du site et les plans des + # locataires PRIVES (`serveur_ops_roles_prives`, lus en SSH par cle de deploiement). + - name: L'organisation du génome existe-t-elle ? + ansible.builtin.uri: + url: "https://forge.{{ domaine_interne }}/api/v1/orgs/{{ serveur_ops_forge_organisation | default('genome') }}" + validate_certs: false + status_code: [200, 404] + register: genome_org + + - name: Créer l'organisation du génome + ansible.builtin.uri: + url: "https://forge.{{ domaine_interne }}/api/v1/orgs" + method: POST + url_username: forgejo-admin + url_password: "{{ vault_forgejo_admin }}" + force_basic_auth: true + validate_certs: false + body_format: json + body: + username: "{{ serveur_ops_forge_organisation | default('genome') }}" + visibility: public + status_code: 201 + no_log: true + when: genome_org.status == 404 + + - name: Quels dépôts poussés existent déjà sur la forge ? + ansible.builtin.uri: + url: "https://forge.{{ domaine_interne }}/api/v1/repos/{{ serveur_ops_forge_organisation | default('genome') }}/{{ item.depot }}" + url_username: forgejo-admin + url_password: "{{ vault_forgejo_admin }}" + force_basic_auth: true + validate_certs: false + status_code: [200, 404] + loop: "{{ genome_a_pousser }}" + loop_control: + label: "{{ item.depot }}" + no_log: true + register: genome_depots_forge + + - name: Créer les dépôts qui manquent + ansible.builtin.uri: + url: "https://forge.{{ domaine_interne }}/api/v1/orgs/{{ serveur_ops_forge_organisation | default('genome') }}/repos" + method: POST + url_username: forgejo-admin + url_password: "{{ vault_forgejo_admin }}" + force_basic_auth: true + validate_certs: false + body_format: json + body: + name: "{{ item.item.depot }}" + private: "{{ item.item.role in (serveur_ops_roles_prives | default(['instance', 'tenant', 'hebergeur'])) }}" + default_branch: "{{ item.item.branche | default('main') }}" + auto_init: false + status_code: 201 + loop: "{{ genome_depots_forge.results }}" + loop_control: + label: "{{ item.item.depot }}" + no_log: true + when: item.status == 404 + - name: Lire la clé publique du runner ansible.builtin.slurp: src: "{{ genome_racine }}/.ssh/id_{{ serveur_ops_cle_type | default('ed25519') }}.pub" diff --git a/playbooks/proxmox/cloner_vm_debian.yml b/playbooks/proxmox/cloner_vm_debian.yml index 738e642..f2aa3b9 100644 --- a/playbooks/proxmox/cloner_vm_debian.yml +++ b/playbooks/proxmox/cloner_vm_debian.yml @@ -21,13 +21,17 @@ # 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 # (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 CARTE DESIGNEE (2026-10-13) : `SETOPS_UNDERLAY` quand il est pose, le symlink sinon. + # Lire le symlink seul aurait clone les machines du site de TechnoLibre sur le cluster + # de Chezlepro, avec ses jetons — l'inventaire, lui, suivait deja la variable. + setops_carte_underlay: "{{ lookup('ansible.builtin.env', 'SETOPS_UNDERLAY') | default(playbook_dir ~ '/../../underlay.yml', true) }}" + setops_proxmox_hebergeur: "{{ setops_carte_underlay | 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" + setops_voute_underlay: "{{ setops_carte_underlay | realpath | dirname }}/underlay.vault.yml" tasks: - name: Verifier les fichiers de variables Proxmox optionnels diff --git a/playbooks/proxmox/supprimer_vm_debian.yml b/playbooks/proxmox/supprimer_vm_debian.yml index e648f54..599aebe 100644 --- a/playbooks/proxmox/supprimer_vm_debian.yml +++ b/playbooks/proxmox/supprimer_vm_debian.yml @@ -22,7 +22,11 @@ # 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 # (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 CARTE DESIGNEE (2026-10-13) : `SETOPS_UNDERLAY` quand il est pose, le symlink sinon. + # Lire le symlink seul aurait clone les machines du site de TechnoLibre sur le cluster + # de Chezlepro, avec ses jetons — l'inventaire, lui, suivait deja la variable. + setops_carte_underlay: "{{ lookup('ansible.builtin.env', 'SETOPS_UNDERLAY') | default(playbook_dir ~ '/../../underlay.yml', true) }}" + setops_proxmox_hebergeur: "{{ setops_carte_underlay | realpath | dirname }}/proxmox-hebergeur.yml" # cibles_vmid : liste d'entiers, passée en -e (sinon dérivée des hôtes actifs). cibles_vmid: "{{ groups['hotes_actifs'] | map('extract', hostvars, 'proxmox_vmid') | map('int') | list }}" diff --git a/roles/common_packages/tasks/minuteurs.yml b/roles/common_packages/tasks/minuteurs.yml index e136288..17a7227 100644 --- a/roles/common_packages/tasks/minuteurs.yml +++ b/roles/common_packages/tasks/minuteurs.yml @@ -54,10 +54,14 @@ # UN PASSAGE DEJA PARTI se termine avant nos operations apt, plutot que de se les disputer. # `is-active` rend 0 si l'une au moins des unites tourne. +# `check_mode: false` : une LECTURE. Sautee en simulation, elle n'avait jamais de `rc`, la +# condition `until` ne se remplissait pas, et `--check` echouait la apres 30 essais sur +# chaque machine (mesure le 2026-10-13, premiere simulation du site de TechnoLibre). - name: Attendre la fin d'un passage apt automatique deja en cours ansible.builtin.command: argv: ["systemctl", "is-active", "--quiet", "apt-daily.service", "apt-daily-upgrade.service"] register: common_packages_passage + check_mode: false changed_when: false failed_when: false until: common_packages_passage.rc != 0 diff --git a/roles/serveur_icingaweb2/tasks/main.yml b/roles/serveur_icingaweb2/tasks/main.yml index 2cfb9c3..205b697 100644 --- a/roles/serveur_icingaweb2/tasks/main.yml +++ b/roles/serveur_icingaweb2/tasks/main.yml @@ -79,89 +79,6 @@ passe deviné : renseigner la clé dans la voûte de cet écosystème. when: serveur_icingaweb2_auth == 'db' -# LE CLIENT POSTGRESQL DES MODULES `community.postgresql`, SUR CETTE MACHINE (2026-10-03). -# -# Les requetes ci-dessous s'executent sur l'hote de la vigie, pas sur le serveur de base. -# Au site, les deux sont la meme machine : `serveur_postgresql` y avait deja pose -# `python3-psycopg2`, et l'absence ne se voyait pas. Chez un locataire, la vigie vit sur -# `mon-01` et la base sur `data-sql-01` — premier passage apres que la base est devenue -# commune a tous les modes : `No module named 'psycopg2'`, masque par `no_log`. -- name: Installer le client PostgreSQL de Python (modules community.postgresql) - ansible.builtin.apt: - name: python3-psycopg2 - state: present - -# LE SCHEMA NE SE CHARGE QU'UNE FOIS. Le rejouer sur une base deja peuplee echouerait sur -# les objets existants — on lit donc d'abord si la table des comptes est la. -- name: La base de la console est-elle déjà en place ? - community.postgresql.postgresql_query: - login_host: "{{ serveur_icingaweb2_db_host }}" - login_user: "{{ serveur_icingaweb2_db_utilisateur }}" - login_password: "{{ serveur_icingaweb2_db_motdepasse }}" - login_db: "{{ serveur_icingaweb2_db_base }}" - ssl_mode: "{{ serveur_icingaweb2_db_sslmode | default('prefer', true) or 'prefer' }}" - ca_cert: "{{ serveur_icingaweb2_db_sslrootcert if serveur_icingaweb2_db_sslmode | default('') else omit }}" - query: "SELECT to_regclass('public.icingaweb_user') IS NOT NULL AS presente" - register: serveur_icingaweb2_schema_etat - changed_when: false - no_log: true - when: - - not ansible_check_mode - -- name: Charger le schéma de la console (comptes, préférences, migrations) - community.postgresql.postgresql_script: - login_host: "{{ serveur_icingaweb2_db_host }}" - login_user: "{{ serveur_icingaweb2_db_utilisateur }}" - login_password: "{{ serveur_icingaweb2_db_motdepasse }}" - login_db: "{{ serveur_icingaweb2_db_base }}" - ssl_mode: "{{ serveur_icingaweb2_db_sslmode | default('prefer', true) or 'prefer' }}" - ca_cert: "{{ serveur_icingaweb2_db_sslrootcert if serveur_icingaweb2_db_sslmode | default('') else omit }}" - path: "{{ serveur_icingaweb2_schema }}" - no_log: true - when: - - not ansible_check_mode - - not (serveur_icingaweb2_schema_etat.query_result[0].presente | default(false)) - -# LE HACHAGE EST CELUI QUE L'APPLICATION VERIFIE, PAS UN QU'ON CHOISIT. `password_hash()` -# de PHP est exactement la fonction qu'Icinga Web 2 appelle a la connexion ; produire un -# hachage d'un autre outil donnerait une base valide et une connexion impossible. -- name: Hacher le mot de passe du compte d'amorçage - ansible.builtin.command: - argv: - - php - - -r - - 'echo password_hash($argv[1], PASSWORD_DEFAULT);' - - "{{ serveur_icingaweb2_admin_motdepasse }}" - register: serveur_icingaweb2_hachage - changed_when: false - no_log: true - when: - - serveur_icingaweb2_auth == 'db' - - not ansible_check_mode - -# CREE S'IL MANQUE, JAMAIS ECRASE. Un mot de passe change dans l'interface appartient a -# celui qui l'a change : le moteur ne le reprend pas au deploiement suivant. Il ne pose -# que le compte qui permet d'entrer la premiere fois. -- name: Poser le compte d'amorçage s'il n'existe pas - community.postgresql.postgresql_query: - login_host: "{{ serveur_icingaweb2_db_host }}" - login_user: "{{ serveur_icingaweb2_db_utilisateur }}" - login_password: "{{ serveur_icingaweb2_db_motdepasse }}" - login_db: "{{ serveur_icingaweb2_db_base }}" - ssl_mode: "{{ serveur_icingaweb2_db_sslmode | default('prefer', true) or 'prefer' }}" - ca_cert: "{{ serveur_icingaweb2_db_sslrootcert if serveur_icingaweb2_db_sslmode | default('') else omit }}" - query: >- - INSERT INTO icingaweb_user (name, active, password_hash) - VALUES (%s, 1, %s) ON CONFLICT (name) DO NOTHING - positional_args: - - "{{ serveur_icingaweb2_admin_utilisateur }}" - - "{{ serveur_icingaweb2_hachage.stdout }}" - no_log: true - when: - - serveur_icingaweb2_auth == 'db' - - not ansible_check_mode - - # ON NE RESOUT PAS UN ANNUAIRE QUAND ON N'EN A PAS (2026-09-14). # # `resoudre_annuaire` etait appele SANS CONDITION. Au site — qui n'a ni LDAP ni Keycloak — @@ -259,6 +176,94 @@ | map('regex_replace', '^(?:Purg|Remv) (\\S+).*$', '\\1') | list) is subset(serveur_icingaweb2_apache_restes) +# LA BASE ET LE COMPTE D'AMORCAGE APRES LES PAQUETS (2026-10-13). Ce bloc venait AVANT +# l'installation : il lit `/usr/share/icingaweb2/schema/pgsql.schema.sql` et hache par `php`, +# deux choses que les paquets apportent. Invisible la ou la console etait deja installee ; +# sur la premiere machine neuve (site de TechnoLibre), le schema n'existait pas encore. + +# LE CLIENT POSTGRESQL DES MODULES `community.postgresql`, SUR CETTE MACHINE (2026-10-03). +# +# Les requetes ci-dessous s'executent sur l'hote de la vigie, pas sur le serveur de base. +# Au site, les deux sont la meme machine : `serveur_postgresql` y avait deja pose +# `python3-psycopg2`, et l'absence ne se voyait pas. Chez un locataire, la vigie vit sur +# `mon-01` et la base sur `data-sql-01` — premier passage apres que la base est devenue +# commune a tous les modes : `No module named 'psycopg2'`, masque par `no_log`. +- name: Installer le client PostgreSQL de Python (modules community.postgresql) + ansible.builtin.apt: + name: python3-psycopg2 + state: present + +# LE SCHEMA NE SE CHARGE QU'UNE FOIS. Le rejouer sur une base deja peuplee echouerait sur +# les objets existants — on lit donc d'abord si la table des comptes est la. +- name: La base de la console est-elle déjà en place ? + community.postgresql.postgresql_query: + login_host: "{{ serveur_icingaweb2_db_host }}" + login_user: "{{ serveur_icingaweb2_db_utilisateur }}" + login_password: "{{ serveur_icingaweb2_db_motdepasse }}" + login_db: "{{ serveur_icingaweb2_db_base }}" + ssl_mode: "{{ serveur_icingaweb2_db_sslmode | default('prefer', true) or 'prefer' }}" + ca_cert: "{{ serveur_icingaweb2_db_sslrootcert if serveur_icingaweb2_db_sslmode | default('') else omit }}" + query: "SELECT to_regclass('public.icingaweb_user') IS NOT NULL AS presente" + register: serveur_icingaweb2_schema_etat + changed_when: false + no_log: true + when: + - not ansible_check_mode + +- name: Charger le schéma de la console (comptes, préférences, migrations) + community.postgresql.postgresql_script: + login_host: "{{ serveur_icingaweb2_db_host }}" + login_user: "{{ serveur_icingaweb2_db_utilisateur }}" + login_password: "{{ serveur_icingaweb2_db_motdepasse }}" + login_db: "{{ serveur_icingaweb2_db_base }}" + ssl_mode: "{{ serveur_icingaweb2_db_sslmode | default('prefer', true) or 'prefer' }}" + ca_cert: "{{ serveur_icingaweb2_db_sslrootcert if serveur_icingaweb2_db_sslmode | default('') else omit }}" + path: "{{ serveur_icingaweb2_schema }}" + no_log: true + when: + - not ansible_check_mode + - not (serveur_icingaweb2_schema_etat.query_result[0].presente | default(false)) + +# LE HACHAGE EST CELUI QUE L'APPLICATION VERIFIE, PAS UN QU'ON CHOISIT. `password_hash()` +# de PHP est exactement la fonction qu'Icinga Web 2 appelle a la connexion ; produire un +# hachage d'un autre outil donnerait une base valide et une connexion impossible. +- name: Hacher le mot de passe du compte d'amorçage + ansible.builtin.command: + argv: + - php + - -r + - 'echo password_hash($argv[1], PASSWORD_DEFAULT);' + - "{{ serveur_icingaweb2_admin_motdepasse }}" + register: serveur_icingaweb2_hachage + changed_when: false + no_log: true + when: + - serveur_icingaweb2_auth == 'db' + - not ansible_check_mode + +# CREE S'IL MANQUE, JAMAIS ECRASE. Un mot de passe change dans l'interface appartient a +# celui qui l'a change : le moteur ne le reprend pas au deploiement suivant. Il ne pose +# que le compte qui permet d'entrer la premiere fois. +- name: Poser le compte d'amorçage s'il n'existe pas + community.postgresql.postgresql_query: + login_host: "{{ serveur_icingaweb2_db_host }}" + login_user: "{{ serveur_icingaweb2_db_utilisateur }}" + login_password: "{{ serveur_icingaweb2_db_motdepasse }}" + login_db: "{{ serveur_icingaweb2_db_base }}" + ssl_mode: "{{ serveur_icingaweb2_db_sslmode | default('prefer', true) or 'prefer' }}" + ca_cert: "{{ serveur_icingaweb2_db_sslrootcert if serveur_icingaweb2_db_sslmode | default('') else omit }}" + query: >- + INSERT INTO icingaweb_user (name, active, password_hash) + VALUES (%s, 1, %s) ON CONFLICT (name) DO NOTHING + positional_args: + - "{{ serveur_icingaweb2_admin_utilisateur }}" + - "{{ serveur_icingaweb2_hachage.stdout }}" + no_log: true + when: + - serveur_icingaweb2_auth == 'db' + - not ansible_check_mode + + - name: Ajouter www-data au groupe icingaweb2 (lecture de la config) ansible.builtin.user: name: www-data diff --git a/scripts/prouver.py b/scripts/prouver.py index 61e0373..451e017 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -4079,10 +4079,19 @@ def _repli_hotes_du_site(enfants: dict, hv: dict) -> list[str]: fautes.append(f"rechargement : {h} renouvelle son certificat sans recharger " f"nginx — le nouveau reste sur disque, l'ancien est servi") if membres("serveur_artefacts"): + porteurs = set(membres("serveur_artefacts")) for h, v in sorted(hv.items()): - if (v or {}).get("dns_amorcage") and not (v or {}).get("artefacts_amorcage"): - fautes.append(f"jumeaux : {h} recoit `dns_amorcage` sans " - f"`artefacts_amorcage` — le gabarit garde son ancien proxy apt") + v = v or {} + if not v.get("dns_amorcage") or v.get("artefacts_amorcage"): + continue + # VIDE SUR LA MACHINE QUI PORTE LE CACHE, ET SEULEMENT LA (2026-10-13). Elle ne + # peut pas s'amorcer sur un cache qu'elle n'a pas encore : le socle RETIRE alors + # le fichier du gabarit (`artefacts_amorcage` defini et vide). Absent, ou vide + # ailleurs, c'est toujours le repli silencieux que cette garde existe pour voir. + if h in porteurs and "artefacts_amorcage" in v: + continue + fautes.append(f"jumeaux : {h} recoit `dns_amorcage` sans " + f"`artefacts_amorcage` — le gabarit garde son ancien proxy apt") return fautes diff --git a/scripts/site_inventaire.py b/scripts/site_inventaire.py index 5b868aa..114d52c 100755 --- a/scripts/site_inventaire.py +++ b/scripts/site_inventaire.py @@ -978,6 +978,16 @@ def inventaire() -> dict: if _frontiere_metriques else [])} if (_fabric_metriques and "serveur_prometheus" in _groupes_de(nom)) else {}), **communes, + # LE CACHE NE S'AMORCE PAS SUR LUI-MEME (2026-10-13). Toute machine du site + # recevait `artefacts_amorcage` = le cache — y compris le cache, et des le + # socle, alors qu'il n'est deploye qu'a la couche `services`. Le premier `apt` + # d'un site construit depuis zero aurait echoue partout ; celui de Chezlepro + # n'a jamais ete reconstruit, son cache existait deja. VIDE ici, et non absent : + # le socle RETIRE alors le fichier du gabarit (qui vise `10.0.33.21`, une adresse + # d'avant le renumerotage) et la machine va droit chez Debian, ce que la + # frontiere permet au cache du site. + **({"artefacts_amorcage": ""} + if _cache_site and str(srv.get("ip")) == _cache_site else {}), # En dernier : ce qu'une machine declare d'elle-meme prime sur ce que le # plan declare pour toutes. **(srv.get("variables") or {}), diff --git a/scripts/site_machines.py b/scripts/site_machines.py index 1706e40..91473d9 100755 --- a/scripts/site_machines.py +++ b/scripts/site_machines.py @@ -20,6 +20,7 @@ validee, et `make prouver` s'en assure avant. from __future__ import annotations import argparse +import os import shlex import sys from pathlib import Path @@ -174,6 +175,23 @@ def parametres(nom: str) -> dict[str, str]: # un autre stockage sans que tout le site bascule. p["SETOPS_STOCKAGE"] = str(m.get("stockage") or mat.get("stockage") or "") p["SETOPS_CLONE_COMPLET"] = "true" if mat.get("clone_complet", True) else "false" + # CE QUE LE PLAN DU SITE DECLARE DEJA, TRANSMIS AU CLONE (2026-10-13). `site-creer` ne + # passait ni cle, ni domaine, ni resolveur : la VM recevait ceux du GABARIT, ne au site de + # Chezlepro — sa cle (`royaume chezlepro`) et `searchdomain: chezlepro.ca`. Au site de + # TechnoLibre, dont le plan dit `cle_ssh: …technolibre`, les neuf VM seraient nees + # FERMEES a Ansible. La cle manque : refus, plutot qu'une VM qu'on ne peut plus joindre. + intr = U.lire_plan_site("10-intrants.yml") or {} + cle = str(intr.get("cle_ssh") or "").strip() + if cle: + pub = Path(os.path.expanduser(cle + ".pub")) + if not pub.is_file(): + raise SystemExit(f"machine '{nom}': `cle_ssh: {cle}` au plan du site, mais " + f"{pub} est absente — la VM naitrait sans cle qu'Ansible possede") + p["SETOPS_CLE_PUBLIQUE"] = str(pub) + if str(intr.get("domaine_interne") or "").strip(): + p["SETOPS_DOMAINE"] = str(intr["domaine_interne"]).strip() + if str(intr.get("dns_amorcage") or "").strip(): + p["SETOPS_DNS"] = str(intr["dns_amorcage"]).strip() return p