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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-13 23:46:46 -04:00
parent f9e45b1676
commit 810670cd30
11 changed files with 290 additions and 95 deletions

View file

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

View file

@ -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=<nom> 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=<nom>
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; \

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 {}),

View file

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