Compare commits
115 commits
essai_inte
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cca22f47ca | |||
| 4660a8892a | |||
| 6141bc4b2f | |||
| e8907285d5 | |||
| e9215a2141 | |||
| 2cbfa2df9b | |||
| f04e790d2c | |||
| 12caff1cff | |||
| 543791e859 | |||
| 11b5bb5733 | |||
| a204ead8ab | |||
| e7f1a74040 | |||
| 8294e5dc71 | |||
| 4532a98707 | |||
| 40af2d1ec6 | |||
| 2e9b31bef7 | |||
| d0a6002b87 | |||
| 013c1b5279 | |||
| 8c1e8c725b | |||
| 44c24e62e4 | |||
| 2beef04dc6 | |||
| d6591cb271 | |||
| 92a6fb0ead | |||
| ce84d0e1ea | |||
| 56033afd00 | |||
| 3c16ef45cd | |||
| 78c2c25761 | |||
| 3d01208a68 | |||
| 077c6c91b4 | |||
| 8251022148 | |||
| c891d7fb26 | |||
| 42e23885cd | |||
| 0d7578b367 | |||
| f42d30b38a | |||
| 5d7d2a8918 | |||
| 1167cc12e0 | |||
| 7976df08d5 | |||
| 6570b757a4 | |||
| 5340220192 | |||
| 055aed665e | |||
| 05ec7a8743 | |||
| b3debd6ca9 | |||
| 49f3d28865 | |||
| 16675037f5 | |||
| 715f9de84d | |||
| badaf753f8 | |||
| 4ee149a0c6 | |||
| f11bb11ad7 | |||
| 2f275d8a7d | |||
| b4ae92c03f | |||
| 8d40c687e1 | |||
| 8b008a8cf4 | |||
| 9414dca750 | |||
| 6a8fc53084 | |||
| a4b6565056 | |||
| 7a2f179f39 | |||
| c1408be9bb | |||
| c2fd6d17ca | |||
| e858fe60d3 | |||
| ff518e18c2 | |||
| 25fd4fc6f1 | |||
| 0318412a9e | |||
| 85932d948c | |||
| 34bfd43e06 | |||
| 209730b62a | |||
| 8603ec4c19 | |||
| d2e2bb8990 | |||
| 49a7e12850 | |||
| b42bc51752 | |||
| b5fa0e8d23 | |||
| 2bf94acb96 | |||
| f425fb6cb3 | |||
| 20a5e44803 | |||
| 249d37d2c4 | |||
| a5a21ee4c5 | |||
| 5a6d0f28f6 | |||
| 14bf977bd6 | |||
| cc906af918 | |||
| 9167f67c7b | |||
| b135b09488 | |||
| 844568af08 | |||
| d9a8905fc7 | |||
| 515aac415a | |||
| 8a9da0c31a | |||
| d3437a6308 | |||
| 502feff6e0 | |||
| bdbf7d8317 | |||
| f3e8883083 | |||
| be06def899 | |||
| cc3a4eec30 | |||
| 5f23201075 | |||
| f818143b49 | |||
| b01de94493 | |||
| 4678ca0223 | |||
| 80bd8e88ac | |||
| 8186389309 | |||
| 6d31842b83 | |||
| cac0b75b3a | |||
| 0cd08663e7 | |||
| 2fde5c7ae7 | |||
| 3fe7e3cea3 | |||
| 2903d75831 | |||
| 5eca6ff4f8 | |||
| 37753984a7 | |||
| e9b03399b9 | |||
| e84b155a5f | |||
| 712a7da1fd | |||
| 81cdb84f66 | |||
| 2695013b32 | |||
| e68f315625 | |||
| 1493059271 | |||
| 1bd211d62b | |||
| efb1b3d334 | |||
| eee6851c3b | |||
| ba2020d486 |
218 changed files with 14480 additions and 597 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -9,6 +9,9 @@ facts_cache/
|
||||||
**/vault.yml
|
**/vault.yml
|
||||||
# Underlay = fabric physique de l'operateur (cluster-global) ; gabarit public seul.
|
# Underlay = fabric physique de l'operateur (cluster-global) ; gabarit public seul.
|
||||||
/underlay.yml
|
/underlay.yml
|
||||||
|
# Contexte actif de CETTE machine (`site:<depot>` ou `locataire:<depot>`) : propre a chaque
|
||||||
|
# poste et a chaque runner, jamais versionne. Voir scripts/contexte.py.
|
||||||
|
/contexte
|
||||||
*.secret
|
*.secret
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
|
|
|
||||||
|
|
@ -188,7 +188,7 @@ Si `ansible-lint` n’est pas disponible, le signaler clairement. Ne pas invente
|
||||||
## Écrire, puis relire (D-68)
|
## Écrire, puis relire (D-68)
|
||||||
|
|
||||||
`--syntax-check` et `ansible-lint` prouvent que le dépôt est cohérent **avec lui-même**.
|
`--syntax-check` et `ansible-lint` prouvent que le dépôt est cohérent **avec lui-même**.
|
||||||
C'est aussi ce que font les 83 preuves de `make prouver` : elles lisent le dépôt, sans le
|
C'est aussi ce que font les 94 preuves de `make prouver` : elles lisent le dépôt, sans le
|
||||||
moindre appel réseau. **Aucune ne demande au système déployé s'il ressemble à ce que le
|
moindre appel réseau. **Aucune ne demande au système déployé s'il ressemble à ce que le
|
||||||
dépôt annonce.**
|
dépôt annonce.**
|
||||||
|
|
||||||
|
|
|
||||||
2713
CHANGELOG.md
2713
CHANGELOG.md
File diff suppressed because it is too large
Load diff
196
Makefile
196
Makefile
|
|
@ -21,7 +21,16 @@ export SETOPS_INSTANCE ?= instance
|
||||||
#
|
#
|
||||||
# `?=` : une valeur deja posee dans l'environnement gagne — la GUI et les runners peuvent
|
# `?=` : une valeur deja posee dans l'environnement gagne — la GUI et les runners peuvent
|
||||||
# nommer leurs propres cles sans que ce fichier ait a les connaitre.
|
# nommer leurs propres cles sans que ce fichier ait a les connaitre.
|
||||||
export ANSIBLE_VAULT_IDENTITY_LIST ?= $(shell python3 scripts/voutes.py identites 2>/dev/null)
|
#
|
||||||
|
# VIDE, ON N'EXPORTE RIEN (2026-10-04). Sur un clone sans aucune cle (la CI de la forge),
|
||||||
|
# `voutes.py` ne rend rien, et l'export posait quand meme `ANSIBLE_VAULT_IDENTITY_LIST=`.
|
||||||
|
# Ansible lit cette chaine vide comme un FICHIER de mot de passe, la resout en repertoire
|
||||||
|
# courant et refuse (« can not be a directory ») : `make ci` mourait a sa premiere commande
|
||||||
|
# sur un clone nu. Le poste a toujours des cles, il ne pouvait pas le voir.
|
||||||
|
SETOPS_VOUTES_IDENTITES := $(shell python3 scripts/voutes.py identites 2>/dev/null)
|
||||||
|
ifneq ($(strip $(SETOPS_VOUTES_IDENTITES)),)
|
||||||
|
export ANSIBLE_VAULT_IDENTITY_LIST ?= $(SETOPS_VOUTES_IDENTITES)
|
||||||
|
endif
|
||||||
# Inventaire de l'instance : un seul par instance dans le modèle « séparation par
|
# Inventaire de l'instance : un seul par instance dans le modèle « séparation par
|
||||||
# instance ». Détection rétro-compatible : principal > production > lab.
|
# instance ». Détection rétro-compatible : principal > production > lab.
|
||||||
# Surchargeable : make … SETOPS_INVENTAIRE=chemin/hosts.yml
|
# Surchargeable : make … SETOPS_INVENTAIRE=chemin/hosts.yml
|
||||||
|
|
@ -308,6 +317,8 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire)
|
||||||
python3 scripts/tests/test_inventory_host.py
|
python3 scripts/tests/test_inventory_host.py
|
||||||
python3 scripts/tests/test_raser.py
|
python3 scripts/tests/test_raser.py
|
||||||
python3 scripts/tests/test_raser_resultat.py
|
python3 scripts/tests/test_raser_resultat.py
|
||||||
|
python3 scripts/tests/test_temoins.py
|
||||||
|
python3 scripts/tests/test_genome_colis.py
|
||||||
python3 scripts/tests/test_adressage_derive.py
|
python3 scripts/tests/test_adressage_derive.py
|
||||||
python3 scripts/tests/test_gui_intrants.py
|
python3 scripts/tests/test_gui_intrants.py
|
||||||
python3 scripts/tests/test_runbooks.py
|
python3 scripts/tests/test_runbooks.py
|
||||||
|
|
@ -317,6 +328,15 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire)
|
||||||
python3 scripts/tests/test_fraicheur_icinga.py
|
python3 scripts/tests/test_fraicheur_icinga.py
|
||||||
python3 scripts/tests/test_sondes_conditionnelles.py
|
python3 scripts/tests/test_sondes_conditionnelles.py
|
||||||
python3 scripts/tests/test_sondes_syntaxe.py
|
python3 scripts/tests/test_sondes_syntaxe.py
|
||||||
|
python3 scripts/tests/test_empreinte_plancher.py
|
||||||
|
python3 scripts/tests/test_expositions_sans_port.py
|
||||||
|
python3 scripts/tests/test_proxmox_fw_externe.py
|
||||||
|
python3 scripts/tests/test_sonde_tcp.py
|
||||||
|
python3 scripts/tests/test_restauration.py
|
||||||
|
python3 scripts/tests/test_repertoires_partages.py
|
||||||
|
python3 scripts/tests/test_frontiere_refus.py
|
||||||
|
python3 scripts/tests/test_contexte.py
|
||||||
|
python3 scripts/tests/test_appels_locataire.py
|
||||||
|
|
||||||
# LA SONDE QUI RAPPORTE CE QUI DISTINGUE (2026-08-28). Trois faux diagnostics en une
|
# LA SONDE QUI RAPPORTE CE QUI DISTINGUE (2026-08-28). Trois faux diagnostics en une
|
||||||
# journee, tous dus a l'instrument : `curl` et `bash /dev/tcp` ecrasent « la machine
|
# journee, tous dus a l'instrument : `curl` et `bash /dev/tcp` ecrasent « la machine
|
||||||
|
|
@ -536,8 +556,12 @@ hote-afficher: ansible-runtime ## Affiche tout ce que le plan derive pour un hot
|
||||||
python3 scripts/inventory_host.py --inventaire $(FICHIER_INVENTAIRE) afficher --hote $(HOTE)
|
python3 scripts/inventory_host.py --inventaire $(FICHIER_INVENTAIRE) afficher --hote $(HOTE)
|
||||||
|
|
||||||
appliquer: ansible-runtime ## Applique un groupe a la flotte — GROUPE=<groupe>
|
appliquer: ansible-runtime ## Applique un groupe a la flotte — GROUPE=<groupe>
|
||||||
@if [[ -z "$(GROUPE)" ]]; then \
|
@# `-z "$(GROUPE)"` NE POUVAIT JAMAIS SE DECLENCHER (2026-10-03) : `GROUPE ?=
|
||||||
printf '%s\n' 'Refus: relancer avec GROUPE=nom_groupe.'; \
|
@# $(GROUPE_DEBIAN)` lui donne toujours une valeur. Une commande coupee par un retour
|
||||||
|
@# a la ligne (`make appliquer` seul) a ainsi applique `serveur_debian` a toute la flotte
|
||||||
|
@# de Technolibre. On refuse donc un groupe que l'operateur n'a pas DONNE lui-meme.
|
||||||
|
@if [[ "$(origin GROUPE)" == "file" || -z "$(GROUPE)" ]]; then \
|
||||||
|
printf '%s\n' 'Refus: relancer avec GROUPE=nom_groupe (aucun groupe par defaut ici).'; \
|
||||||
exit 2; \
|
exit 2; \
|
||||||
fi
|
fi
|
||||||
@if [[ ! -f "$(DOSSIER_PLAYBOOKS_GROUPES)/$(GROUPE).yml" ]]; then \
|
@if [[ ! -f "$(DOSSIER_PLAYBOOKS_GROUPES)/$(GROUPE).yml" ]]; then \
|
||||||
|
|
@ -545,7 +569,9 @@ appliquer: ansible-runtime ## Applique un groupe a la flotte — GROUPE=<groupe>
|
||||||
exit 2; \
|
exit 2; \
|
||||||
fi
|
fi
|
||||||
python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) --dependances $(FICHIER_DEPENDANCES) verifier-dependances-groupe --groupe $(GROUPE)
|
python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) --dependances $(FICHIER_DEPENDANCES) verifier-dependances-groupe --groupe $(GROUPE)
|
||||||
ansible-playbook -i $(INVENTAIRE_PRODUCTION) "$(DOSSIER_PLAYBOOKS_GROUPES)/$(GROUPE).yml" --limit '$(GROUPE):&$(GROUPE_HOTES_ACTIFS)'
|
@# `ARGS` comme chez `site-appliquer` (2026-10-03) : simuler (`ARGS="--check --diff"`)
|
||||||
|
@# avant d'appliquer doit etre possible par la meme porte que l'application elle-meme.
|
||||||
|
ansible-playbook -i $(INVENTAIRE_PRODUCTION) "$(DOSSIER_PLAYBOOKS_GROUPES)/$(GROUPE).yml" --limit '$(GROUPE):&$(GROUPE_HOTES_ACTIFS)' $(ARGS)
|
||||||
|
|
||||||
# ATTENDRE PLUTOT QUE CONSTATER (2026-08-27). `deployer` faisait un `ping` unique
|
# ATTENDRE PLUTOT QUE CONSTATER (2026-08-27). `deployer` faisait un `ping` unique
|
||||||
# (`_verifier-acces-hote`). Depuis que `creer-vm` ne guette plus le SSH — il prouve la
|
# (`_verifier-acces-hote`). Depuis que `creer-vm` ne guette plus le SSH — il prouve la
|
||||||
|
|
@ -587,7 +613,7 @@ site: ansible-runtime ## Regenere playbooks/site.yml depuis les couches et le gr
|
||||||
site-verifier: ## Verifie que playbooks/site.yml correspond aux couches declarees
|
site-verifier: ## Verifie que playbooks/site.yml correspond aux couches declarees
|
||||||
python3 scripts/orchestrer.py verifier
|
python3 scripts/orchestrer.py verifier
|
||||||
|
|
||||||
.PHONY: flux flux-verifier
|
.PHONY: flux flux-verifier fiches-site-deposer
|
||||||
cles-recenser: ## Montre ce qui n'existe QUE sur ce poste (sans rien ecrire)
|
cles-recenser: ## Montre ce qui n'existe QUE sur ce poste (sans rien ecrire)
|
||||||
python3 scripts/exporter_cles.py --recenser
|
python3 scripts/exporter_cles.py --recenser
|
||||||
|
|
||||||
|
|
@ -723,8 +749,8 @@ proxmox-fw-activer-vm: ansible-runtime ## Active le pare-feu d'UNE VM en le prou
|
||||||
|
|
||||||
.PHONY: proxmox-fw-eprouver proxmox-fw-activer-vm
|
.PHONY: proxmox-fw-eprouver proxmox-fw-activer-vm
|
||||||
.PHONY: placement-plan sdn-plan sdn-appliquer
|
.PHONY: placement-plan sdn-plan sdn-appliquer
|
||||||
placement-plan: ansible-runtime ## Le noeud, stockage, pont et gabarit du tenant existent-ils sur ce cluster ? (aucune ecriture)
|
placement-plan: ansible-runtime ## Le noeud, stockage, pont et gabarit du tenant existent-ils sur ce cluster ? (aucune ecriture) [TENANT=<depot>]
|
||||||
python3 scripts/devis_placement.py
|
python3 scripts/devis_placement.py $(if $(TENANT),--locataire $(TENANT))
|
||||||
|
|
||||||
sdn-plan: ansible-runtime ## Ecart entre le SDN EVPN (+ sortie des VRF) et son devis (aucune ecriture)
|
sdn-plan: ansible-runtime ## Ecart entre le SDN EVPN (+ sortie des VRF) et son devis (aucune ecriture)
|
||||||
python3 scripts/appliquer_sdn.py
|
python3 scripts/appliquer_sdn.py
|
||||||
|
|
@ -847,6 +873,17 @@ site-intrants: ## Les intrants que le SITE monte expose a ses locataires (derive
|
||||||
site-intrants-verifier: ## Le locataire monte suit-il encore son site ? (aucune ecriture)
|
site-intrants-verifier: ## Le locataire monte suit-il encore son site ? (aucune ecriture)
|
||||||
python3 scripts/site_intrants.py --verifier
|
python3 scripts/site_intrants.py --verifier
|
||||||
|
|
||||||
|
face-reseau-publier: ## Chaque locataire publie sa face reseau pour son site (a commiter chez lui, apres `make flux`)
|
||||||
|
@# LE LOCATAIRE PUBLIE, LE SITE LIT (2026-10-05). Le site lisait les fichiers internes de ses
|
||||||
|
@# locataires ; il lit desormais `face-reseau.yml`. P92 refuse une face perimee.
|
||||||
|
python3 scripts/contexte.py --publier-faces
|
||||||
|
|
||||||
|
fiches-site-deposer: ## Le site depose chez chacun de ses locataires la fiche qu'il lui destine (a commiter chez eux)
|
||||||
|
@# LE SITE DEPOSE, LE LOCATAIRE LIT (2026-10-05). Le runner d'un locataire n'a pas le depot
|
||||||
|
@# de son site : ce que le site lui attribue et lui offre lui parvient par ce fichier,
|
||||||
|
@# `fiche-site.yml`, que l'instancier lit. P91 refuse une fiche perimee.
|
||||||
|
python3 scripts/contexte.py --deposer-fiches
|
||||||
|
|
||||||
.PHONY: vpn-admin-plan vpn-admin-appliquer
|
.PHONY: vpn-admin-plan vpn-admin-appliquer
|
||||||
vpn-admin-plan: ## Acces WireGuard des admins : ce que la frontiere porte face au plan (aucune ecriture)
|
vpn-admin-plan: ## Acces WireGuard des admins : ce que la frontiere porte face au plan (aucune ecriture)
|
||||||
python3 scripts/vpn_admin.py plan
|
python3 scripts/vpn_admin.py plan
|
||||||
|
|
@ -896,6 +933,39 @@ flux-verifier: ## Verifie que le registre des flux correspond aux meta/flux.yml
|
||||||
valider: ansible-runtime ## Passe la recette de validation sur la flotte
|
valider: ansible-runtime ## Passe la recette de validation sur la flotte
|
||||||
ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/valider.yml
|
ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/valider.yml
|
||||||
|
|
||||||
|
.PHONY: restauration-etat restauration-renoncer sauvegarder-maintenant temoins-etat
|
||||||
|
sauvegarder-maintenant: ansible-runtime ## Depose l'etat de chaque noeud TOUT DE SUITE (avant de raser) — HOTE=<hote> optionnel
|
||||||
|
@set -e; $(VAULT_UNE_FOIS) \
|
||||||
|
ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/backup/restauration.yml \
|
||||||
|
-e restauration_action=sauvegarder $(if $(HOTE),--limit "$(HOTE)")
|
||||||
|
|
||||||
|
# LA RESTAURATION DANS LA RECONSTRUCTION (2026-09-30) — la remise elle-meme n'a pas de
|
||||||
|
# cible : chaque role proprietaire la fait au deploiement. Ces deux cibles-ci servent a
|
||||||
|
# VOIR ce qui attend, et a ECARTER un etat qu'on ne veut pas remettre.
|
||||||
|
restauration-etat: ansible-runtime ## Ce qui attend dans le depot, et ce que chaque jeu est devenu — HOTE=<hote> optionnel
|
||||||
|
@set -e; $(VAULT_UNE_FOIS) \
|
||||||
|
ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/backup/restauration.yml \
|
||||||
|
$(if $(HOTE),--limit "$(HOTE)")
|
||||||
|
|
||||||
|
# LES TEMOINS (2026-10-07) — l'etat remis est-il celui d'avant ? Compare l'etat vivant a
|
||||||
|
# l'instantane etiquete au rasage (`sauvegarder-maintenant`). `SOURCE=candidat` compare au
|
||||||
|
# dernier instantane d'avant la naissance du noeud, faute d'etiquette. Lecture seule.
|
||||||
|
temoins-etat: ansible-runtime ## L'etat vivant est-il celui d'avant le rasage ? (lecture seule) — HOTE= SOURCE=candidat optionnels
|
||||||
|
@set -e; $(VAULT_UNE_FOIS) \
|
||||||
|
ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/backup/restauration.yml \
|
||||||
|
-e restauration_action=temoins $(if $(SOURCE),-e restauration_source="$(SOURCE)") \
|
||||||
|
$(if $(HOTE),--limit "$(HOTE)")
|
||||||
|
|
||||||
|
restauration-renoncer: ansible-runtime ## Ecarte l'etat anterieur d'un jeu, sans le remettre — HOTE= JEU= CONFIRMER=true
|
||||||
|
@set -e; \
|
||||||
|
if [[ "$(CONFIRMER)" != "true" || -z "$(HOTE)" || -z "$(JEU)" ]]; then \
|
||||||
|
printf '%s\n' 'Refus: ECARTER un etat anterieur (il sortira de la retention). HOTE=, JEU= et CONFIRMER=true requis.'; \
|
||||||
|
exit 2; \
|
||||||
|
fi; \
|
||||||
|
$(VAULT_UNE_FOIS) \
|
||||||
|
ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/backup/restauration.yml \
|
||||||
|
--limit "$(HOTE)" -e restauration_action=renoncer -e restauration_jeu="$(JEU)"
|
||||||
|
|
||||||
.PHONY: schema
|
.PHONY: schema
|
||||||
schema: ## Regenere docs/audit/schema-plan.json depuis les registres et les validateurs
|
schema: ## Regenere docs/audit/schema-plan.json depuis les registres et les validateurs
|
||||||
python3 scripts/schema_plan.py
|
python3 scripts/schema_plan.py
|
||||||
|
|
@ -1080,14 +1150,23 @@ define VAULT_UNE_FOIS
|
||||||
endef
|
endef
|
||||||
|
|
||||||
|
|
||||||
flotte-creer: _instance-requise ## Cree les VM manquantes de la flotte depuis le plan — PARALLELE=n
|
# LE SITE MATERIALISE UN LOCATAIRE NOMME, D'APRES SA FACE (2026-10-05, docs/conception-contextes.md,
|
||||||
|
# materialisation M2). Avec `TENANT=<depot>`, la liste des machines et leurs parametres de clonage
|
||||||
|
# viennent de `face-reseau.yml` que le locataire publie — rendus a l'octet pres comme
|
||||||
|
# `parametres-proxmox` (P93) — et aucune instance n'est montee. Sans `TENANT`, l'ancienne lecture
|
||||||
|
# de l'instance montee (le poste, le laboratoire).
|
||||||
|
LISTE_ACTIFS = $(if $(TENANT),python3 scripts/contexte.py --hotes-actifs $(TENANT),python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) lister-actifs)
|
||||||
|
PARAMS_CLONAGE = $(if $(TENANT),python3 scripts/contexte.py --parametres-clonage $(TENANT) --hote,python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) parametres-proxmox --hote)
|
||||||
|
INSTANCE_OU_LOCATAIRE = $(if $(TENANT),,_instance-requise)
|
||||||
|
|
||||||
|
flotte-creer: $(INSTANCE_OU_LOCATAIRE) ## Cree les VM manquantes de la flotte depuis le plan — PARALLELE=n
|
||||||
@set -e; \
|
@set -e; \
|
||||||
if [[ "$(CONFIRMER)" != "true" ]]; then \
|
if [[ "$(CONFIRMER)" != "true" ]]; then \
|
||||||
printf '%s\n' 'Refus: creation de TOUTES les VM actives du plan (clone Proxmox).'; \
|
printf '%s\n' 'Refus: creation de TOUTES les VM actives du plan (clone Proxmox).'; \
|
||||||
printf '%s\n' 'Relancer avec CONFIRMER=true.'; \
|
printf '%s\n' 'Relancer avec CONFIRMER=true.'; \
|
||||||
exit 2; \
|
exit 2; \
|
||||||
fi; \
|
fi; \
|
||||||
hotes="$$(python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) lister-actifs)"; \
|
hotes="$$($(LISTE_ACTIFS))"; \
|
||||||
if [[ -z "$$hotes" ]]; then printf '%s\n' 'Refus: aucun hote actif dans le plan.'; exit 2; fi; \
|
if [[ -z "$$hotes" ]]; then printf '%s\n' 'Refus: aucun hote actif dans le plan.'; exit 2; fi; \
|
||||||
par="$(PARALLELE)"; par="$${par:-4}"; \
|
par="$(PARALLELE)"; par="$${par:-4}"; \
|
||||||
tmp="$$(mktemp -d)"; trap 'rm -rf "$$tmp"' EXIT; \
|
tmp="$$(mktemp -d)"; trap 'rm -rf "$$tmp"' EXIT; \
|
||||||
|
|
@ -1096,7 +1175,7 @@ flotte-creer: _instance-requise ## Cree les VM manquantes de la flotte depuis le
|
||||||
n=0; \
|
n=0; \
|
||||||
for h in $$hotes; do \
|
for h in $$hotes; do \
|
||||||
printf '\n=== Creation VM: %s ===\n' "$$h"; \
|
printf '\n=== Creation VM: %s ===\n' "$$h"; \
|
||||||
( $(MAKE) creer-vm HOTE="$$h" > "$$tmp/$$h.log" 2>&1; printf '%s' "$$?" > "$$tmp/$$h.rc" ) & \
|
( $(MAKE) creer-vm HOTE="$$h" $(if $(TENANT),TENANT="$(TENANT)") > "$$tmp/$$h.log" 2>&1; printf '%s' "$$?" > "$$tmp/$$h.rc" ) & \
|
||||||
n=$$((n+1)); \
|
n=$$((n+1)); \
|
||||||
if (( n >= par )); then wait -n || true; n=$$((n-1)); fi; \
|
if (( n >= par )); then wait -n || true; n=$$((n-1)); fi; \
|
||||||
done; \
|
done; \
|
||||||
|
|
@ -1111,6 +1190,13 @@ flotte-creer: _instance-requise ## Cree les VM manquantes de la flotte depuis le
|
||||||
if (( echec )); then printf '\nAu moins une VM n a pas ete creee — rien ne continue.\n'; exit 2; fi; \
|
if (( echec )); then printf '\nAu moins une VM n a pas ete creee — rien ne continue.\n'; exit 2; fi; \
|
||||||
printf '\nToutes les VM actives sont creees.\n'
|
printf '\nToutes les VM actives sont creees.\n'
|
||||||
|
|
||||||
|
locataire-creer: ## Le SITE cree les VM d'un locataire d'apres sa face reseau — TENANT=<depot> CONFIRMER=true [PARALLELE=n]
|
||||||
|
@if [[ -z "$(TENANT)" ]]; then printf '%s\n' 'Refus: TENANT=<depot du locataire> requis.'; exit 2; fi
|
||||||
|
@$(MAKE) --no-print-directory flotte-creer TENANT="$(TENANT)" CONFIRMER="$(CONFIRMER)" PARALLELE="$(PARALLELE)"
|
||||||
|
locataire-raser: ## DESTRUCTIF : le SITE rase les VM d'un locataire d'apres sa face reseau — TENANT=<depot> CONFIRMER=true INSTANCE=<nom court> [HOTE=h]
|
||||||
|
@if [[ -z "$(TENANT)" ]]; then printf '%s\n' 'Refus: TENANT=<depot du locataire> requis.'; exit 2; fi
|
||||||
|
@$(MAKE) --no-print-directory raser TENANT="$(TENANT)" CONFIRMER="$(CONFIRMER)" INSTANCE="$(INSTANCE)" HOTE="$(HOTE)"
|
||||||
|
|
||||||
# --- LES MACHINES DU SITE -----------------------------------------------------
|
# --- LES MACHINES DU SITE -----------------------------------------------------
|
||||||
#
|
#
|
||||||
# UN SITE N'EST PAS UN PLAN. Un tenant se derive de son `index` ; un site n'en a pas et
|
# UN SITE N'EST PAS UN PLAN. Un tenant se derive de son `index` ; un site n'en a pas et
|
||||||
|
|
@ -1132,6 +1218,10 @@ site-inventaire: ## L'inventaire dynamique du site, tel qu'Ansible le voit
|
||||||
|
|
||||||
.PHONY: site-appliquer
|
.PHONY: site-appliquer
|
||||||
site-appliquer: ## Applique un role aux machines du site — GROUPE=<serveur_ops_site|...>
|
site-appliquer: ## Applique un role aux machines du site — GROUPE=<serveur_ops_site|...>
|
||||||
|
@if [[ "$(origin GROUPE)" == "file" || -z "$(GROUPE)" ]]; then \
|
||||||
|
printf '%s\n' 'Refus: relancer avec GROUPE=nom_groupe (aucun groupe par defaut ici).'; \
|
||||||
|
exit 2; \
|
||||||
|
fi
|
||||||
@# `GROUPE` a un defaut global (GROUPE_DEBIAN) : tester s'il est VIDE ne protege de
|
@# `GROUPE` a un defaut global (GROUPE_DEBIAN) : tester s'il est VIDE ne protege de
|
||||||
@# rien, ce garde-fou-la ne pourrait jamais se declencher. Le vrai risque, observe :
|
@# rien, ce garde-fou-la ne pourrait jamais se declencher. Le vrai risque, observe :
|
||||||
@# lancer un playbook de TENANT contre l'inventaire du SITE. Ansible n'y trouve aucun
|
@# lancer un playbook de TENANT contre l'inventaire du SITE. Ansible n'y trouve aucun
|
||||||
|
|
@ -1372,8 +1462,8 @@ _attendre-flotte: ansible-runtime
|
||||||
printf 'Flotte joignable.\n'
|
printf 'Flotte joignable.\n'
|
||||||
|
|
||||||
.PHONY: reconstruire
|
.PHONY: reconstruire
|
||||||
raser: ansible-runtime _instance-requise ## DESTRUCTIF : detruit les VM derivees du plan — exige CONFIRMER=true ET INSTANCE=<nom>
|
raser: ansible-runtime $(INSTANCE_OU_LOCATAIRE) ## DESTRUCTIF : detruit les VM derivees du plan — exige CONFIRMER=true ET INSTANCE=<nom> [TENANT=<depot>]
|
||||||
@python3 scripts/raser.py $(if $(INSTANCE),--instance $(INSTANCE)) $(if $(HOTE),--hote $(HOTE)) $(if $(filter true,$(CONFIRMER)),--confirmer)
|
@python3 scripts/raser.py $(if $(TENANT),--locataire $(TENANT)) $(if $(INSTANCE),--instance $(INSTANCE)) $(if $(HOTE),--hote $(HOTE)) $(if $(filter true,$(CONFIRMER)),--confirmer)
|
||||||
|
|
||||||
# `make flux` D'ABORD, et ce n'est pas une precaution : sans lui, `instance/flux-genere/`
|
# `make flux` D'ABORD, et ce n'est pas une precaution : sans lui, `instance/flux-genere/`
|
||||||
# est VIDE et le socle deploie nftables en `policy drop` SANS AUCUNE REGLE DERIVEE. La
|
# est VIDE et le socle deploie nftables en `policy drop` SANS AUCUNE REGLE DERIVEE. La
|
||||||
|
|
@ -1394,8 +1484,70 @@ reconstruire: _instance-requise ## Reconstruit un ecosysteme depuis zero : VM pu
|
||||||
$(MAKE) flux; \
|
$(MAKE) flux; \
|
||||||
$(MAKE) flotte-creer CONFIRMER=true; \
|
$(MAKE) flotte-creer CONFIRMER=true; \
|
||||||
$(MAKE) _attendre-flotte; \
|
$(MAKE) _attendre-flotte; \
|
||||||
$(MAKE) _amorcer-socle; \
|
$(MAKE) monter-flotte CONFIRMER=true
|
||||||
$(MAKE) deployer-tout CONFIRMER=true
|
|
||||||
|
# MONTER UNE FLOTTE DONT LES VM EXISTENT DEJA (2026-09-30). C'est la seconde moitie de
|
||||||
|
# `reconstruire`, et TOUT ce que le runner d'un locataire peut en faire : il ne cree pas de VM
|
||||||
|
# (l'API du cluster est a l'hebergeur), il les recoit, armees, et les monte. Jusqu'ici cette
|
||||||
|
# sequence vivait dans un script ecrit a la main dans /tmp du runner — deux reconstructions
|
||||||
|
# l'ont portee, et aucune trace ne la gardait.
|
||||||
|
#
|
||||||
|
# `set -e` : la premiere etape en echec arrete tout. Chaque etape est horodatee, pour qu'un
|
||||||
|
# journal dise ou l'on en etait sans relire dix mille lignes d'Ansible. `valider` fait partie
|
||||||
|
# de la sequence : une flotte sans recette n'est pas montee, elle est deployee.
|
||||||
|
.PHONY: monter-flotte
|
||||||
|
monter-flotte: _instance-requise ## Monte une flotte deja creee : flux, AC et DNS, tout le reste, recette — CONFIRMER=true
|
||||||
|
@set -e; \
|
||||||
|
if [[ "$(CONFIRMER)" != "true" ]]; then \
|
||||||
|
printf '%s\n' 'Refus: MONTER la flotte (deploiement complet puis recette). Relancer avec CONFIRMER=true.'; \
|
||||||
|
exit 2; \
|
||||||
|
fi; \
|
||||||
|
etape() { printf '\n=== ETAPE %s %s\n' "$$1" "$$(date +%T)"; }; \
|
||||||
|
etape flux; $(MAKE) flux; \
|
||||||
|
etape _amorcer-socle; $(MAKE) _amorcer-socle; \
|
||||||
|
etape deployer-tout; $(MAKE) deployer-tout CONFIRMER=true; \
|
||||||
|
etape valider; $(MAKE) valider; \
|
||||||
|
printf '\n=== FLOTTE MONTEE %s\n' "$$(date +%T)"
|
||||||
|
|
||||||
|
# RECONSTRUIRE UN LOCATAIRE DE BOUT EN BOUT, DEPUIS LE POSTE (2026-09-30). Les deux
|
||||||
|
# reconstructions du 2026-09-30 passaient par cinq endroits et des commandes tapees a la
|
||||||
|
# main ; la sequence vit desormais dans `scripts/reconstruire_locataire.py` (voir son
|
||||||
|
# en-tete). `TENANT=` designe l'ecosysteme en toutes lettres : le nom qu'exige `raser` en est
|
||||||
|
# derive, pas redemande. L'armement marque une pause, sauf `ARMER=oui`. `DEPUIS=<etape>` reprend apres un
|
||||||
|
# arret. Le journal complet va dans `<locataire>/logs/`.
|
||||||
|
.PHONY: reconstruire-locataire
|
||||||
|
reconstruire-locataire: ## Rase et reconstruit un locataire, du site a la recette, etat remis — TENANT=OPS-X CONFIRMER=true
|
||||||
|
@if [[ -z "$(TENANT)" ]]; then \
|
||||||
|
printf '%s\n' 'Refus: TENANT=<OPS-X> requis (le dossier du locataire, en toutes lettres).'; \
|
||||||
|
exit 2; \
|
||||||
|
fi
|
||||||
|
python3 scripts/reconstruire_locataire.py --tenant "$(TENANT)" \
|
||||||
|
$(if $(filter true,$(CONFIRMER)),--confirmer) $(if $(DEPUIS),--depuis "$(DEPUIS)") \
|
||||||
|
$(if $(filter oui,$(ARMER)),--armer)
|
||||||
|
|
||||||
|
# LE PARE-FEU PROXMOX D'UNE FLOTTE RECONSTRUITE (2026-09-30). Une VM clonee nait sans les
|
||||||
|
# options de pare-feu : chaque reconstruction les perd. La procedure (une VM a la fois,
|
||||||
|
# matrice avant/apres, Icinga) etait rejouee par une boucle tapee a la main ; `--flotte` la
|
||||||
|
# porte, avec la regle qui la rend sure : arret au premier refus.
|
||||||
|
#
|
||||||
|
# PAR LES SONDES DEPUIS LE 2026-09-30 (`--sondes`) : la matrice prenait une vingtaine de
|
||||||
|
# minutes en fin de reconstruction. Chaque VM teste desormais chaque minute ses flux promis
|
||||||
|
# (sonde `connectivite`) ; on active tout d'un coup et on lit Icinga. La matrice reste pour
|
||||||
|
# le diagnostic : `python3 scripts/eprouver_parefeu.py --instance OPS-X --hote <vm> --plan`.
|
||||||
|
#
|
||||||
|
# DEPUIS LE POSTE : la matrice se joue avec les acces du LOCATAIRE, l'activation passe par
|
||||||
|
# le runner du SITE, seul a joindre l'API du cluster. Le poste est le seul a tenir les deux.
|
||||||
|
.PHONY: parefeu-verifier-flotte parefeu-activer-flotte
|
||||||
|
parefeu-verifier-flotte: ## La sonde `connectivite` est-elle saine sur chaque VM (prealable a l'activation) — INSTANCE=OPS-X
|
||||||
|
@if [[ -z "$(INSTANCE)" ]]; then printf '%s\n' 'Refus: INSTANCE=<OPS-X> requis.'; exit 2; fi
|
||||||
|
python3 scripts/eprouver_parefeu.py --instance "$(INSTANCE)" --flotte --sondes --plan
|
||||||
|
|
||||||
|
parefeu-activer-flotte: ## Active le pare-feu Proxmox de toutes les VM, juge par la sonde `connectivite` — INSTANCE=OPS-X CONFIRMER=true
|
||||||
|
@if [[ -z "$(INSTANCE)" || "$(CONFIRMER)" != "true" ]]; then \
|
||||||
|
printf '%s\n' 'Refus: INSTANCE=<OPS-X> et CONFIRMER=true requis (active le pare-feu de TOUTES les VM, une a une).'; \
|
||||||
|
exit 2; \
|
||||||
|
fi
|
||||||
|
python3 scripts/eprouver_parefeu.py --instance "$(INSTANCE)" --flotte --sondes --activer
|
||||||
|
|
||||||
# « Go ahead, make my day. » — LE bouton rouge : alias de reconstruire (Clint Eastwood).
|
# « Go ahead, make my day. » — LE bouton rouge : alias de reconstruire (Clint Eastwood).
|
||||||
# Cree toutes les VM puis deploie tout, en une commande. Garde CONFIRMER=true.
|
# Cree toutes les VM puis deploie tout, en une commande. Garde CONFIRMER=true.
|
||||||
|
|
@ -1420,8 +1572,12 @@ _amorcer-socle: ansible-runtime _instance-requise
|
||||||
myDay: reconstruire ## Alias strict de `reconstruire` (meme cible, memes gardes)
|
myDay: reconstruire ## Alias strict de `reconstruire` (meme cible, memes gardes)
|
||||||
|
|
||||||
deployer-groupe: ## Deploie un seul groupe sur toute la flotte — GROUPE=<groupe>
|
deployer-groupe: ## Deploie un seul groupe sur toute la flotte — GROUPE=<groupe>
|
||||||
@if [[ -z "$(GROUPE)" ]]; then \
|
@# `-z "$(GROUPE)"` NE POUVAIT JAMAIS SE DECLENCHER (2026-10-03) : `GROUPE ?=
|
||||||
printf '%s\n' 'Refus: relancer avec GROUPE=nom_groupe.'; \
|
@# $(GROUPE_DEBIAN)` lui donne toujours une valeur. Une commande coupee par un retour
|
||||||
|
@# a la ligne (`make appliquer` seul) a ainsi applique `serveur_debian` a toute la flotte
|
||||||
|
@# de Technolibre. On refuse donc un groupe que l'operateur n'a pas DONNE lui-meme.
|
||||||
|
@if [[ "$(origin GROUPE)" == "file" || -z "$(GROUPE)" ]]; then \
|
||||||
|
printf '%s\n' 'Refus: relancer avec GROUPE=nom_groupe (aucun groupe par defaut ici).'; \
|
||||||
exit 2; \
|
exit 2; \
|
||||||
fi
|
fi
|
||||||
$(MAKE) appliquer GROUPE="$(GROUPE)"
|
$(MAKE) appliquer GROUPE="$(GROUPE)"
|
||||||
|
|
@ -1489,7 +1645,7 @@ cloner-vm: ansible-runtime ## Clone une VM depuis le gabarit dore — HOTE=<nom>
|
||||||
-e proxmox_clone_nom="$(HOTE)" \
|
-e proxmox_clone_nom="$(HOTE)" \
|
||||||
-e proxmox_clone_vmid="$(VMID)" \
|
-e proxmox_clone_vmid="$(VMID)" \
|
||||||
-e proxmox_clone_ipconfig0="$$ipconfig" \
|
-e proxmox_clone_ipconfig0="$$ipconfig" \
|
||||||
-e proxmox_clone_pool="$(if $(POOL),$(POOL),$$(python3 scripts/devis_proxmox_pools.py --pool-actif))" \
|
-e proxmox_clone_pool="$(if $(POOL),$(POOL),$$(python3 scripts/devis_proxmox_pools.py $(if $(TENANT),--pool-du-locataire $(TENANT),--pool-actif)))" \
|
||||||
); \
|
); \
|
||||||
[[ -n "$(VMID_MODELE)" ]] && extra_vars+=( -e proxmox_clone_vmid_modele="$(VMID_MODELE)" ); \
|
[[ -n "$(VMID_MODELE)" ]] && extra_vars+=( -e proxmox_clone_vmid_modele="$(VMID_MODELE)" ); \
|
||||||
[[ -n "$(NOEUD_PROXMOX)" ]] && extra_vars+=( -e proxmox_clone_noeud="$(NOEUD_PROXMOX)" ); \
|
[[ -n "$(NOEUD_PROXMOX)" ]] && extra_vars+=( -e proxmox_clone_noeud="$(NOEUD_PROXMOX)" ); \
|
||||||
|
|
@ -1532,13 +1688,13 @@ cloner-vm: ansible-runtime ## Clone une VM depuis le gabarit dore — HOTE=<nom>
|
||||||
fi; \
|
fi; \
|
||||||
ansible-playbook -i localhost, $(PLAYBOOK_PROXMOX_CLONER_VM) "$${vault_args[@]}" "$${extra_vars[@]}"
|
ansible-playbook -i localhost, $(PLAYBOOK_PROXMOX_CLONER_VM) "$${vault_args[@]}" "$${extra_vars[@]}"
|
||||||
|
|
||||||
creer-vm: _instance-requise ## Cree une VM et attend qu'elle soit joignable — HOTE=<nom>
|
creer-vm: $(INSTANCE_OU_LOCATAIRE) ## Cree une VM et attend qu'elle soit joignable — HOTE=<nom> [TENANT=<depot>]
|
||||||
@set -e; \
|
@set -e; \
|
||||||
if [[ -z "$(HOTE)" ]]; then \
|
if [[ -z "$(HOTE)" ]]; then \
|
||||||
printf '%s\n' 'Refus: relancer avec HOTE=nom_hote (declare dans le plan).'; \
|
printf '%s\n' 'Refus: relancer avec HOTE=nom_hote (declare dans le plan).'; \
|
||||||
exit 2; \
|
exit 2; \
|
||||||
fi; \
|
fi; \
|
||||||
params="$$(python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) parametres-proxmox --hote $(HOTE))"; \
|
params="$$($(PARAMS_CLONAGE) $(HOTE))"; \
|
||||||
eval "$$params"; \
|
eval "$$params"; \
|
||||||
export SETOPS_CLES_AMORCAGE; \
|
export SETOPS_CLES_AMORCAGE; \
|
||||||
$(MAKE) cloner-vm \
|
$(MAKE) cloner-vm \
|
||||||
|
|
@ -1579,7 +1735,7 @@ creer-vm: _instance-requise ## Cree une VM et attend qu'elle soit joignable —
|
||||||
@# ATTENDRE LA DISPONIBILITE est un autre geste, deplace chez `deployer` : cloud-init
|
@# ATTENDRE LA DISPONIBILITE est un autre geste, deplace chez `deployer` : cloud-init
|
||||||
@# et dpkg se guettent juste avant de configurer, par celui qui va configurer.
|
@# et dpkg se guettent juste avant de configurer, par celui qui va configurer.
|
||||||
@if [[ "$(ATTENDRE)" != "false" ]]; then \
|
@if [[ "$(ATTENDRE)" != "false" ]]; then \
|
||||||
params="$$(python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) parametres-proxmox --hote $(HOTE))"; \
|
params="$$($(PARAMS_CLONAGE) $(HOTE))"; \
|
||||||
eval "$$params"; \
|
eval "$$params"; \
|
||||||
python3 scripts/attendre_materialisation.py \
|
python3 scripts/attendre_materialisation.py \
|
||||||
--vmid "$$SETOPS_VMID" --ip "$$SETOPS_IP" --hote "$(HOTE)" \
|
--vmid "$$SETOPS_VMID" --ip "$$SETOPS_IP" --hote "$(HOTE)" \
|
||||||
|
|
|
||||||
120
docs/audit/preuve-2026-09-30.md
Normal file
120
docs/audit/preuve-2026-09-30.md
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
# Preuve de conformite — Set-OPS — 2026-09-30
|
||||||
|
|
||||||
|
> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer
|
||||||
|
> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;
|
||||||
|
> aucune validation n'est reimplementee ici. Voir le mode d'emploi :
|
||||||
|
> [`docs/audit/README.md`](README.md), et le registre trace :
|
||||||
|
> [`docs/audit/affirmations.md`](affirmations.md).
|
||||||
|
|
||||||
|
- **Instance** : `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance` — inventaire `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance/inventories/principal/hosts.yml`
|
||||||
|
- **Verdict** : ❌ NON CONFORME (81 OK · 1 echec · 1 saute)
|
||||||
|
|
||||||
|
## Preuves
|
||||||
|
|
||||||
|
| # | Preuve | Affirmations | Statut | Detail |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | } \| to_nice_json }}`. |
|
||||||
|
| P02 | Tests unitaires (inventaire, raser, ecritures du plan, rendu du GUI) | — | ✅ OK | OK |
|
||||||
|
| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 4 instance(s) verifiee(s) — instance-ci-1646753, OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre : plan et inventaire applique coincident. |
|
||||||
|
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
|
||||||
|
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
|
||||||
|
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
|
||||||
|
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check), 1 nom(s) surveille(s) sans reference orpheline. |
|
||||||
|
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 42 groupes classes, aucun cycle, aucune arete en arriere ; playbooks/site.yml a jour. |
|
||||||
|
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 40 rôles, 121 flux, schéma + matrice OK. |
|
||||||
|
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
|
||||||
|
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_resolveur_site |
|
||||||
|
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
|
||||||
|
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
|
||||||
|
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
|
||||||
|
| 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 : 32 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 30 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) et 7 zone(s) de site : adressage 100% derive du seed index. |
|
||||||
|
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 5 instance(s) federee(s), aucun index en collision. |
|
||||||
|
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). |
|
||||||
|
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 14 reseau(x), aucune collision avec la plage tenant. |
|
||||||
|
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | note : serveur_powerdns declare un port `derive` que le plan du site ne resout pas — aucune regle emise. |
|
||||||
|
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 2 tenant(s), 40 groupe(s), 108 regle(s). |
|
||||||
|
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 13 hote(s) x 6 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). |
|
||||||
|
| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. |
|
||||||
|
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 35 VM placee(s), aucun nom ni VMID en collision. |
|
||||||
|
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 34 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 23, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
|
||||||
|
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 2 zone(s), 12 VNet(s), 12 sous-reseau(x), aucune collision. |
|
||||||
|
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 77 scripts expliques et atteignables, 146 cibles make documentees, 69 roles avec README. |
|
||||||
|
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (142 cle(s) declaree(s) par l'instance). |
|
||||||
|
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 44 revendication(s) de port, aucune collision entre roles co-localises (35 groupes). |
|
||||||
|
| P34 | Chaque document declare son lecteur | — | ✅ OK | 49 document(s) declarent leur lecteur (44 genere(s) exempte(s)). |
|
||||||
|
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 4 application(s) exigeant une base l'ont toutes (3 entree(s) au registre). |
|
||||||
|
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 7 hote(s) de l'ecosysteme et 3 du site detiennent de l'etat, tous porteurs de `client_backup` (8 groupe(s) au catalogue). |
|
||||||
|
| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. |
|
||||||
|
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 42 role(s) serveur/client tous nommes, 42 groupe(s) cite(s) en table existent tous. |
|
||||||
|
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 97 page(s) de wiki toutes atteignables. |
|
||||||
|
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
|
||||||
|
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 73 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
|
||||||
|
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (instance-ci-1646753/production, OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre |
|
||||||
|
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 9 machine(s) du plan retrouvees, 202 regle(s) du site. |
|
||||||
|
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 integration(s) appliquent leur serveur avant leurs clients. |
|
||||||
|
| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). |
|
||||||
|
| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos |
|
||||||
|
| P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). |
|
||||||
|
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 101 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. |
|
||||||
|
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (139 lignes). |
|
||||||
|
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 309 regles `pass`), tous non consignes et tous motives. |
|
||||||
|
| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 |
|
||||||
|
| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. |
|
||||||
|
| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 13 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b |
|
||||||
|
| P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 9 role(s) applique(s), aucun secret de tenant reclame. |
|
||||||
|
| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 13 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 13. |
|
||||||
|
| P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. |
|
||||||
|
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (83 preuves, 69 roles, 42 groupes). |
|
||||||
|
| P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. |
|
||||||
|
| P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). |
|
||||||
|
| P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `3fe7e3c` (publie le 2026-09-29). |
|
||||||
|
| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 56 champ(s) sur 7 registres ; il couvre tout ce que les plans reels contiennent, et la FORME de chaque champ (scalaire / objet / table) corresp |
|
||||||
|
| P62 | Schema du plan : il decrit tout ce que le MOTEUR accepte | AFF-033 | ✅ OK | Les 4 validateurs n'acceptent aucun champ que le schema ignore (applications:8, bases_donnees:4, domaines_publics:9, serveurs:3 champ(s) lus par validateur). |
|
||||||
|
| P63 | cloud-init nait avec la VM et ne lui survit pas | — | ✅ OK | cloud-init est au gabarit (la premiere seconde), absent du socle (pas de va-et-vient), et retire par le durcissement — avec la garde qui verifie que le reseau s |
|
||||||
|
| P64 | Sondes de supervision : declarees ET deposees | — | ✅ OK | 47 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_journal/journaux, client_metrique/metriques, client_pki/certificat, client_s |
|
||||||
|
| P65 | Depots tiers : demandes au cache, jamais en HTTPS direct | — | ✅ OK | 4 depot(s) tiers relaye(s) par le cache, aucun role ne les vise en https:// ecrit en dur. |
|
||||||
|
| P66 | Clients OIDC : chaque URI vise un nom que le plan expose | — | ✅ OK | 4 client(s) OIDC, toutes leurs URI visent un FQDN que le plan expose (7 exposition(s)). |
|
||||||
|
| P67 | Nom public : le service porte celui du plan, pas celui du role | — | ✅ OK | 14 service(s) expose(s) portent le nom du plan, sur 2 inventaire(s) : instance, SITE. |
|
||||||
|
| P68 | Cle de depot telechargee : mesuree avant d'etre utilisee | — | ✅ OK | 5 role(s) telechargent une cle de depot, tous la mesurent avant de s'en servir. |
|
||||||
|
| P69 | Amorcage d'un tenant : l'adresse designe le site REEL | — | ✅ OK | 2 adresse(s) d'amorcage designent bien une machine du site. |
|
||||||
|
| P70 | Depot de binaires : il tient tout ce que les roles vont chercher | — | ✅ OK | 6 artefact(s) direct(s) tenus par le depot du site. |
|
||||||
|
| P71 | Pool du site : le genome ne nait pas chez un tenant | — | ✅ OK | `site-creer` nomme `--pool-site` ; le pool du genome ne peut plus etre celui d'un tenant. |
|
||||||
|
| P72 | Annuaire : aucun service ne se lie avec le compte du maitre | — | ✅ OK | 5 role(s) consultent l'annuaire, chacun avec SON compte de service ; seul `amorcage_acces` garde celui d'administration, et il provisionne au lieu de consommer. |
|
||||||
|
| P73 | Le locataire designe les services de son site REEL | — | ✅ OK | CONFORME : 10 intrant(s) du locataire concordent avec ce que le site expose (1 non declare(s), donc derive(s) ou non utilise(s)). |
|
||||||
|
| P74 | Gabarit dore : une seule declaration, au plan du site | — | ✅ OK | Gabarit declare une seule fois : VMID 9006 « modeleSetOPS-minimal », precedent 99998. |
|
||||||
|
| P75 | Les parametres de clonage traversent les trois maillons | — | ✅ OK | 14 parametre(s) de clonage, tous emis par l'inventaire. |
|
||||||
|
| P76 | Tout gabarit de role se rend vraiment | — | ✅ OK | 185 gabarits de role : tous se rendent. |
|
||||||
|
| P77 | Panneaux declares : assemblables, et gradues | — | ✅ OK | 8 panneau(x) declare(s) dans 2 role(s), tous avec titre, expression, raison et une unite que la table sait traduire. |
|
||||||
|
| P78 | Un consommateur de base suit le verrou TLS de son serveur | — | ✅ OK | 3 consommateur(s) suivent la posture de leur serveur ; 2 sans reglage TLS (serveur_icingaweb2, serveur_nextcloud). |
|
||||||
|
| P79 | Replis silencieux : une derivation vide ne passe pas pour un succes | — | ❌ ECHEC | 1 repli(s) silencieux — une derivation vide rend un succes :
|
||||||
|
- flux : OPS-Chezlepro-lab — perimees par rapport au plan : ops-01.nft |
|
||||||
|
| P80 | Remise au client : inscrite, nommee, et son second temps a l'heure | — | ✅ OK | Aucun ecosysteme remis a un client : rien a tenir. |
|
||||||
|
| P81 | La console dit sa portee, et ne sert pas un inventaire vide en silence | — | ✅ OK | Portee `poste` derivee des voutes portees, source d'inventaire `instance/inventories/principal/hosts.yml`, et 15 route(s) POST exigent toutes un pouvoir. |
|
||||||
|
| P82 | DNS public : les zones publiees sont servies, signees avant d'etre exposees | — | ✅ OK | 2 zone(s) publique(s) declaree(s), toutes servies par le site ; 2 replication(s) declaree(s) des deux cotes ; transfert ouvert a la cle seule ; aucune expositio |
|
||||||
|
| P83 | Assistants : le registre des runbooks ne prend pas de retard sur le Makefile | — | ✅ OK | 17 runbooks, 139 etapes, 145 cibles documentees : chacune portee par un assistant ou exemptee avec son motif. |
|
||||||
|
|
||||||
|
## Couverture des affirmations ✅ du registre
|
||||||
|
|
||||||
|
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
|
||||||
|
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
|
||||||
|
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
|
||||||
|
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
|
||||||
|
elles restent hors du harnais recurrent (rien d'executable a rejouer).
|
||||||
|
|
||||||
|
## Declarations d'intention (⚪ invérifiables localement — assumees)
|
||||||
|
|
||||||
|
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
|
||||||
|
comme declarations d'intention, non comme preuves :
|
||||||
|
|
||||||
|
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
|
||||||
|
contre une flotte vivante.
|
||||||
|
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
|
||||||
|
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
|
||||||
|
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
|
||||||
|
|
||||||
|
_Rapport genere le 2026-09-30._
|
||||||
119
docs/audit/preuve-2026-10-01.md
Normal file
119
docs/audit/preuve-2026-10-01.md
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
# Preuve de conformite — Set-OPS — 2026-10-01
|
||||||
|
|
||||||
|
> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer
|
||||||
|
> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;
|
||||||
|
> aucune validation n'est reimplementee ici. Voir le mode d'emploi :
|
||||||
|
> [`docs/audit/README.md`](README.md), et le registre trace :
|
||||||
|
> [`docs/audit/affirmations.md`](affirmations.md).
|
||||||
|
|
||||||
|
- **Instance** : `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance` — inventaire `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance/inventories/principal/hosts.yml`
|
||||||
|
- **Verdict** : ✅ CONFORME (82 OK · 0 echec · 1 saute)
|
||||||
|
|
||||||
|
## Preuves
|
||||||
|
|
||||||
|
| # | Preuve | Affirmations | Statut | Detail |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | } \| to_nice_json }}`. |
|
||||||
|
| P02 | Tests unitaires (inventaire, raser, ecritures du plan, rendu du GUI) | — | ✅ OK | OK |
|
||||||
|
| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 4 instance(s) verifiee(s) — instance-ci-1646753, OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre : plan et inventaire applique coincident. |
|
||||||
|
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
|
||||||
|
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
|
||||||
|
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
|
||||||
|
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check), 1 nom(s) surveille(s) sans reference orpheline. |
|
||||||
|
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 42 groupes classes, aucun cycle, aucune arete en arriere ; playbooks/site.yml a jour. |
|
||||||
|
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 40 rôles, 121 flux, schéma + matrice OK. |
|
||||||
|
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
|
||||||
|
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_resolveur_site |
|
||||||
|
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
|
||||||
|
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
|
||||||
|
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
|
||||||
|
| 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 : 32 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 30 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) et 7 zone(s) de site : adressage 100% derive du seed index. |
|
||||||
|
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 5 instance(s) federee(s), aucun index en collision. |
|
||||||
|
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). |
|
||||||
|
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 14 reseau(x), aucune collision avec la plage tenant. |
|
||||||
|
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | note : serveur_powerdns declare un port `derive` que le plan du site ne resout pas — aucune regle emise. |
|
||||||
|
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 2 tenant(s), 40 groupe(s), 108 regle(s). |
|
||||||
|
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 13 hote(s) x 6 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). |
|
||||||
|
| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. |
|
||||||
|
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 35 VM placee(s), aucun nom ni VMID en collision. |
|
||||||
|
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 34 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 23, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
|
||||||
|
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 2 zone(s), 12 VNet(s), 12 sous-reseau(x), aucune collision. |
|
||||||
|
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 77 scripts expliques et atteignables, 146 cibles make documentees, 69 roles avec README. |
|
||||||
|
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (142 cle(s) declaree(s) par l'instance). |
|
||||||
|
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 44 revendication(s) de port, aucune collision entre roles co-localises (35 groupes). |
|
||||||
|
| P34 | Chaque document declare son lecteur | — | ✅ OK | 49 document(s) declarent leur lecteur (45 genere(s) exempte(s)). |
|
||||||
|
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 4 application(s) exigeant une base l'ont toutes (3 entree(s) au registre). |
|
||||||
|
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 7 hote(s) de l'ecosysteme et 3 du site detiennent de l'etat, tous porteurs de `client_backup` (8 groupe(s) au catalogue). |
|
||||||
|
| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. |
|
||||||
|
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 42 role(s) serveur/client tous nommes, 42 groupe(s) cite(s) en table existent tous. |
|
||||||
|
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 97 page(s) de wiki toutes atteignables. |
|
||||||
|
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
|
||||||
|
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 73 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
|
||||||
|
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (instance-ci-1646753/production, OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre |
|
||||||
|
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 9 machine(s) du plan retrouvees, 202 regle(s) du site. |
|
||||||
|
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 integration(s) appliquent leur serveur avant leurs clients. |
|
||||||
|
| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). |
|
||||||
|
| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos |
|
||||||
|
| P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). |
|
||||||
|
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 101 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. |
|
||||||
|
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (139 lignes). |
|
||||||
|
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 309 regles `pass`), tous non consignes et tous motives. |
|
||||||
|
| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 |
|
||||||
|
| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. |
|
||||||
|
| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 13 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere : WAN muet, interfa |
|
||||||
|
| P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 9 role(s) applique(s), aucun secret de tenant reclame. |
|
||||||
|
| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 13 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 13. |
|
||||||
|
| P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. |
|
||||||
|
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (83 preuves, 69 roles, 42 groupes). |
|
||||||
|
| P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. |
|
||||||
|
| P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). |
|
||||||
|
| P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `3fe7e3c` (publie le 2026-09-29). |
|
||||||
|
| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 56 champ(s) sur 7 registres ; il couvre tout ce que les plans reels contiennent, et la FORME de chaque champ (scalaire / objet / table) corresp |
|
||||||
|
| P62 | Schema du plan : il decrit tout ce que le MOTEUR accepte | AFF-033 | ✅ OK | Les 4 validateurs n'acceptent aucun champ que le schema ignore (applications:8, bases_donnees:4, domaines_publics:9, serveurs:3 champ(s) lus par validateur). |
|
||||||
|
| P63 | cloud-init nait avec la VM et ne lui survit pas | — | ✅ OK | cloud-init est au gabarit (la premiere seconde), absent du socle (pas de va-et-vient), et retire par le durcissement — avec la garde qui verifie que le reseau s |
|
||||||
|
| P64 | Sondes de supervision : declarees ET deposees | — | ✅ OK | 47 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_journal/journaux, client_metrique/metriques, client_pki/certificat, client_s |
|
||||||
|
| P65 | Depots tiers : demandes au cache, jamais en HTTPS direct | — | ✅ OK | 4 depot(s) tiers relaye(s) par le cache, aucun role ne les vise en https:// ecrit en dur. |
|
||||||
|
| P66 | Clients OIDC : chaque URI vise un nom que le plan expose | — | ✅ OK | 4 client(s) OIDC, toutes leurs URI visent un FQDN que le plan expose (7 exposition(s)). |
|
||||||
|
| P67 | Nom public : le service porte celui du plan, pas celui du role | — | ✅ OK | 14 service(s) expose(s) portent le nom du plan, sur 2 inventaire(s) : instance, SITE. |
|
||||||
|
| P68 | Cle de depot telechargee : mesuree avant d'etre utilisee | — | ✅ OK | 5 role(s) telechargent une cle de depot, tous la mesurent avant de s'en servir. |
|
||||||
|
| P69 | Amorcage d'un tenant : l'adresse designe le site REEL | — | ✅ OK | 2 adresse(s) d'amorcage designent bien une machine du site. |
|
||||||
|
| P70 | Depot de binaires : il tient tout ce que les roles vont chercher | — | ✅ OK | 6 artefact(s) direct(s) tenus par le depot du site. |
|
||||||
|
| P71 | Pool du site : le genome ne nait pas chez un tenant | — | ✅ OK | `site-creer` nomme `--pool-site` ; le pool du genome ne peut plus etre celui d'un tenant. |
|
||||||
|
| P72 | Annuaire : aucun service ne se lie avec le compte du maitre | — | ✅ OK | 5 role(s) consultent l'annuaire, chacun avec SON compte de service ; seul `amorcage_acces` garde celui d'administration, et il provisionne au lieu de consommer. |
|
||||||
|
| P73 | Le locataire designe les services de son site REEL | — | ✅ OK | CONFORME : 10 intrant(s) du locataire concordent avec ce que le site expose (1 non declare(s), donc derive(s) ou non utilise(s)). |
|
||||||
|
| P74 | Gabarit dore : une seule declaration, au plan du site | — | ✅ OK | Gabarit declare une seule fois : VMID 9006 « modeleSetOPS-minimal », precedent 99998. |
|
||||||
|
| P75 | Les parametres de clonage traversent les trois maillons | — | ✅ OK | 14 parametre(s) de clonage, tous emis par l'inventaire. |
|
||||||
|
| P76 | Tout gabarit de role se rend vraiment | — | ✅ OK | 185 gabarits de role : tous se rendent. |
|
||||||
|
| P77 | Panneaux declares : assemblables, et gradues | — | ✅ OK | 8 panneau(x) declare(s) dans 2 role(s), tous avec titre, expression, raison et une unite que la table sait traduire. |
|
||||||
|
| P78 | Un consommateur de base suit le verrou TLS de son serveur | — | ✅ OK | 3 consommateur(s) suivent la posture de leur serveur ; 2 sans reglage TLS (serveur_icingaweb2, serveur_nextcloud). |
|
||||||
|
| P79 | Replis silencieux : une derivation vide ne passe pas pour un succes | — | ✅ OK | 5 ecosysteme(s) (instance-ci-1646753, OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, SITE-Chezlepro) : pattes, edges, certificats, rechargements, jumeaux d' |
|
||||||
|
| P80 | Remise au client : inscrite, nommee, et son second temps a l'heure | — | ✅ OK | Aucun ecosysteme remis a un client : rien a tenir. |
|
||||||
|
| P81 | La console dit sa portee, et ne sert pas un inventaire vide en silence | — | ✅ OK | Portee `poste` derivee des voutes portees, source d'inventaire `instance/inventories/principal/hosts.yml`, et 15 route(s) POST exigent toutes un pouvoir. |
|
||||||
|
| P82 | DNS public : les zones publiees sont servies, signees avant d'etre exposees | — | ✅ OK | 2 zone(s) publique(s) declaree(s), toutes servies par le site ; 2 replication(s) declaree(s) des deux cotes ; transfert ouvert a la cle seule ; aucune expositio |
|
||||||
|
| P83 | Assistants : le registre des runbooks ne prend pas de retard sur le Makefile | — | ✅ OK | 17 runbooks, 139 etapes, 145 cibles documentees : chacune portee par un assistant ou exemptee avec son motif. |
|
||||||
|
|
||||||
|
## Couverture des affirmations ✅ du registre
|
||||||
|
|
||||||
|
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
|
||||||
|
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
|
||||||
|
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
|
||||||
|
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
|
||||||
|
elles restent hors du harnais recurrent (rien d'executable a rejouer).
|
||||||
|
|
||||||
|
## Declarations d'intention (⚪ invérifiables localement — assumees)
|
||||||
|
|
||||||
|
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
|
||||||
|
comme declarations d'intention, non comme preuves :
|
||||||
|
|
||||||
|
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
|
||||||
|
contre une flotte vivante.
|
||||||
|
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
|
||||||
|
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
|
||||||
|
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
|
||||||
|
|
||||||
|
_Rapport genere le 2026-10-01._
|
||||||
120
docs/audit/preuve-2026-10-03.md
Normal file
120
docs/audit/preuve-2026-10-03.md
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
# Preuve de conformite — Set-OPS — 2026-10-03
|
||||||
|
|
||||||
|
> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer
|
||||||
|
> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;
|
||||||
|
> aucune validation n'est reimplementee ici. Voir le mode d'emploi :
|
||||||
|
> [`docs/audit/README.md`](README.md), et le registre trace :
|
||||||
|
> [`docs/audit/affirmations.md`](affirmations.md).
|
||||||
|
|
||||||
|
- **Instance** : `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance` — inventaire `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance/inventories/principal/hosts.yml`
|
||||||
|
- **Verdict** : ❌ NON CONFORME (81 OK · 1 echec · 1 saute)
|
||||||
|
|
||||||
|
## Preuves
|
||||||
|
|
||||||
|
| # | Preuve | Affirmations | Statut | Detail |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | [0m[0m |
|
||||||
|
| P02 | Tests unitaires (inventaire, raser, ecritures du plan, rendu du GUI) | — | ✅ OK | OK |
|
||||||
|
| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 4 instance(s) verifiee(s) — instance-ci-1646753, OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre : plan et inventaire applique coincident. |
|
||||||
|
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
|
||||||
|
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
|
||||||
|
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
|
||||||
|
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check), 1 nom(s) surveille(s) sans reference orpheline. |
|
||||||
|
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 42 groupes classes, aucun cycle, aucune arete en arriere ; playbooks/site.yml a jour. |
|
||||||
|
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 40 rôles, 121 flux, schéma + matrice OK. |
|
||||||
|
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
|
||||||
|
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_resolveur_site |
|
||||||
|
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
|
||||||
|
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
|
||||||
|
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
|
||||||
|
| 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 : 32 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 30 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) et 7 zone(s) de site : adressage 100% derive du seed index. |
|
||||||
|
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 5 instance(s) federee(s), aucun index en collision. |
|
||||||
|
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). |
|
||||||
|
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 14 reseau(x), aucune collision avec la plage tenant. |
|
||||||
|
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | note : serveur_powerdns declare un port `derive` que le plan du site ne resout pas — aucune regle emise. |
|
||||||
|
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 2 tenant(s), 40 groupe(s), 108 regle(s). |
|
||||||
|
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 13 hote(s) x 6 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). |
|
||||||
|
| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. |
|
||||||
|
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 35 VM placee(s), aucun nom ni VMID en collision. |
|
||||||
|
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 34 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 23, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
|
||||||
|
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 2 zone(s), 12 VNet(s), 12 sous-reseau(x), aucune collision. |
|
||||||
|
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 77 scripts expliques et atteignables, 146 cibles make documentees, 69 roles avec README. |
|
||||||
|
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (142 cle(s) declaree(s) par l'instance). |
|
||||||
|
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 44 revendication(s) de port, aucune collision entre roles co-localises (35 groupes). |
|
||||||
|
| P34 | Chaque document declare son lecteur | — | ✅ OK | 49 document(s) declarent leur lecteur (46 genere(s) exempte(s)). |
|
||||||
|
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 4 application(s) exigeant une base l'ont toutes (3 entree(s) au registre). |
|
||||||
|
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 7 hote(s) de l'ecosysteme et 3 du site detiennent de l'etat, tous porteurs de `client_backup` (8 groupe(s) au catalogue). |
|
||||||
|
| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. |
|
||||||
|
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 42 role(s) serveur/client tous nommes, 42 groupe(s) cite(s) en table existent tous. |
|
||||||
|
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 97 page(s) de wiki toutes atteignables. |
|
||||||
|
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
|
||||||
|
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 73 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
|
||||||
|
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (instance-ci-1646753/production, OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre |
|
||||||
|
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 9 machine(s) du plan retrouvees, 202 regle(s) du site. |
|
||||||
|
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 integration(s) appliquent leur serveur avant leurs clients. |
|
||||||
|
| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). |
|
||||||
|
| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos |
|
||||||
|
| P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). |
|
||||||
|
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ❌ ECHEC | La carte d'orientation ne dit plus vrai :
|
||||||
|
- « pieces d'audit » : la carte annonce 50, le depot en compte 51 |
|
||||||
|
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (139 lignes). |
|
||||||
|
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 309 regles `pass`), tous non consignes et tous motives. |
|
||||||
|
| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 |
|
||||||
|
| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. |
|
||||||
|
| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 13 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere : WAN muet, interfa |
|
||||||
|
| P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 9 role(s) applique(s), aucun secret de tenant reclame. |
|
||||||
|
| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 13 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 13. |
|
||||||
|
| P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. |
|
||||||
|
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (83 preuves, 69 roles, 42 groupes). |
|
||||||
|
| P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. |
|
||||||
|
| P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). |
|
||||||
|
| P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `3fe7e3c` (publie le 2026-09-29). |
|
||||||
|
| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 56 champ(s) sur 7 registres ; il couvre tout ce que les plans reels contiennent, et la FORME de chaque champ (scalaire / objet / table) corresp |
|
||||||
|
| P62 | Schema du plan : il decrit tout ce que le MOTEUR accepte | AFF-033 | ✅ OK | Les 4 validateurs n'acceptent aucun champ que le schema ignore (applications:8, bases_donnees:4, domaines_publics:9, serveurs:3 champ(s) lus par validateur). |
|
||||||
|
| P63 | cloud-init nait avec la VM et ne lui survit pas | — | ✅ OK | cloud-init est au gabarit (la premiere seconde), absent du socle (pas de va-et-vient), et retire par le durcissement — avec la garde qui verifie que le reseau s |
|
||||||
|
| P64 | Sondes de supervision : declarees ET deposees | — | ✅ OK | 47 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_journal/journaux, client_metrique/metriques, client_pki/certificat, client_s |
|
||||||
|
| P65 | Depots tiers : demandes au cache, jamais en HTTPS direct | — | ✅ OK | 4 depot(s) tiers relaye(s) par le cache, aucun role ne les vise en https:// ecrit en dur. |
|
||||||
|
| P66 | Clients OIDC : chaque URI vise un nom que le plan expose | — | ✅ OK | 4 client(s) OIDC, toutes leurs URI visent un FQDN que le plan expose (7 exposition(s)). |
|
||||||
|
| P67 | Nom public : le service porte celui du plan, pas celui du role | — | ✅ OK | 14 service(s) expose(s) portent le nom du plan, sur 2 inventaire(s) : instance, SITE. |
|
||||||
|
| P68 | Cle de depot telechargee : mesuree avant d'etre utilisee | — | ✅ OK | 5 role(s) telechargent une cle de depot, tous la mesurent avant de s'en servir. |
|
||||||
|
| P69 | Amorcage d'un tenant : l'adresse designe le site REEL | — | ✅ OK | 2 adresse(s) d'amorcage designent bien une machine du site. |
|
||||||
|
| P70 | Depot de binaires : il tient tout ce que les roles vont chercher | — | ✅ OK | 6 artefact(s) direct(s) tenus par le depot du site. |
|
||||||
|
| P71 | Pool du site : le genome ne nait pas chez un tenant | — | ✅ OK | `site-creer` nomme `--pool-site` ; le pool du genome ne peut plus etre celui d'un tenant. |
|
||||||
|
| P72 | Annuaire : aucun service ne se lie avec le compte du maitre | — | ✅ OK | 5 role(s) consultent l'annuaire, chacun avec SON compte de service ; seul `amorcage_acces` garde celui d'administration, et il provisionne au lieu de consommer. |
|
||||||
|
| P73 | Le locataire designe les services de son site REEL | — | ✅ OK | CONFORME : 10 intrant(s) du locataire concordent avec ce que le site expose (1 non declare(s), donc derive(s) ou non utilise(s)). |
|
||||||
|
| P74 | Gabarit dore : une seule declaration, au plan du site | — | ✅ OK | Gabarit declare une seule fois : VMID 9006 « modeleSetOPS-minimal », precedent 99998. |
|
||||||
|
| P75 | Les parametres de clonage traversent les trois maillons | — | ✅ OK | 14 parametre(s) de clonage, tous emis par l'inventaire. |
|
||||||
|
| P76 | Tout gabarit de role se rend vraiment | — | ✅ OK | 185 gabarits de role : tous se rendent. |
|
||||||
|
| P77 | Panneaux declares : assemblables, et gradues | — | ✅ OK | 8 panneau(x) declare(s) dans 2 role(s), tous avec titre, expression, raison et une unite que la table sait traduire. |
|
||||||
|
| P78 | Un consommateur de base suit le verrou TLS de son serveur | — | ✅ OK | 3 consommateur(s) suivent la posture de leur serveur ; 2 sans reglage TLS (serveur_icingaweb2, serveur_nextcloud). |
|
||||||
|
| P79 | Replis silencieux : une derivation vide ne passe pas pour un succes | — | ✅ OK | 5 ecosysteme(s) (instance-ci-1646753, OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, SITE-Chezlepro) : pattes, edges, certificats, rechargements, jumeaux d' |
|
||||||
|
| P80 | Remise au client : inscrite, nommee, et son second temps a l'heure | — | ✅ OK | Aucun ecosysteme remis a un client : rien a tenir. |
|
||||||
|
| P81 | La console dit sa portee, et ne sert pas un inventaire vide en silence | — | ✅ OK | Portee `poste` derivee des voutes portees, source d'inventaire `instance/inventories/principal/hosts.yml`, et 15 route(s) POST exigent toutes un pouvoir. |
|
||||||
|
| P82 | DNS public : les zones publiees sont servies, signees avant d'etre exposees | — | ✅ OK | 2 zone(s) publique(s) declaree(s), toutes servies par le site ; 2 replication(s) declaree(s) des deux cotes ; transfert ouvert a la cle seule ; aucune expositio |
|
||||||
|
| P83 | Assistants : le registre des runbooks ne prend pas de retard sur le Makefile | — | ✅ OK | 17 runbooks, 139 etapes, 145 cibles documentees : chacune portee par un assistant ou exemptee avec son motif. |
|
||||||
|
|
||||||
|
## Couverture des affirmations ✅ du registre
|
||||||
|
|
||||||
|
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
|
||||||
|
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
|
||||||
|
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
|
||||||
|
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
|
||||||
|
elles restent hors du harnais recurrent (rien d'executable a rejouer).
|
||||||
|
|
||||||
|
## Declarations d'intention (⚪ invérifiables localement — assumees)
|
||||||
|
|
||||||
|
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
|
||||||
|
comme declarations d'intention, non comme preuves :
|
||||||
|
|
||||||
|
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
|
||||||
|
contre une flotte vivante.
|
||||||
|
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
|
||||||
|
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
|
||||||
|
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
|
||||||
|
|
||||||
|
_Rapport genere le 2026-10-03._
|
||||||
|
|
@ -288,6 +288,15 @@
|
||||||
"description": "Le GROUPE Ansible qui sert cette zone (ex. serveur_nginx).",
|
"description": "Le GROUPE Ansible qui sert cette zone (ex. serveur_nginx).",
|
||||||
"x-source-valeurs": "groupes_edge"
|
"x-source-valeurs": "groupes_edge"
|
||||||
},
|
},
|
||||||
|
"resolution_interne": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Resolution interne",
|
||||||
|
"description": "`service` : le plancher et la zone menent au service lui-meme, l'edge ne sert qu'aux personnes.",
|
||||||
|
"enum": [
|
||||||
|
"edge",
|
||||||
|
"service"
|
||||||
|
]
|
||||||
|
},
|
||||||
"secondaires": {
|
"secondaires": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"title": "Secondaires",
|
"title": "Secondaires",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
# Ecrit par `make wiki-publier`, lu par la preuve P60. Ne pas editer a la main.
|
# Ecrit par `make wiki-publier`, lu par la preuve P60. Ne pas editer a la main.
|
||||||
remote: ssh://git@eregion.chezlepro.ca:2222/Alliance-Boreale/Set-OPS-Public.wiki.git
|
remote: ssh://git@eregion.chezlepro.ca:2222/Alliance-Boreale/Set-OPS-Public.wiki.git
|
||||||
source: 2bbe122
|
source: e9215a2
|
||||||
date: 2026-09-29
|
date: 2026-10-07
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,8 @@ README de rôles). Cette page comble ces deux trous.
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| rôles | 69 | `roles/*/` |
|
| rôles | 69 | `roles/*/` |
|
||||||
| README de rôles | 69 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
|
| README de rôles | 69 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
|
||||||
| documents | 45 | `docs/*.md` |
|
| documents | 46 | `docs/*.md` |
|
||||||
| pièces d'audit | 48 | `docs/audit/*` |
|
| pièces d'audit | 51 | `docs/audit/*` |
|
||||||
| unités de wiki | 27 | `wiki/*.md` |
|
| unités de wiki | 27 | `wiki/*.md` |
|
||||||
| décisions en vigueur | 85 | lignes `\| **D-nn** \|` de `decisions-architecture.md` |
|
| décisions en vigueur | 85 | lignes `\| **D-nn** \|` de `decisions-architecture.md` |
|
||||||
| décisions renversées | 3 | lignes `\| **D-nn** —` du même document |
|
| décisions renversées | 3 | lignes `\| **D-nn** —` du même document |
|
||||||
|
|
@ -36,7 +36,7 @@ README de rôles). Cette page comble ces deux trous.
|
||||||
| Sujet | Documents |
|
| Sujet | Documents |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Autorité / gouvernance** | `AGENTS.md` (source d'autorité), `CLAUDE.md`, `docs/MISE-A-JOUR-CODEX-CLAUDE.md` |
|
| **Autorité / gouvernance** | `AGENTS.md` (source d'autorité), `CLAUDE.md`, `docs/MISE-A-JOUR-CODEX-CLAUDE.md` |
|
||||||
| **Le modèle (plan)** | `docs/architecture-set-ops.md` (survol) → `docs/plan-et-generation.md` (à fond) → `docs/meta-classe.md` (concept) |
|
| **Le modèle (plan)** | `docs/architecture-set-ops.md` (survol) → `docs/plan-et-generation.md` (à fond) → `docs/meta-classe.md` (concept) → `docs/conception-contextes.md` (site et locataire : deux classes, un contrat) |
|
||||||
| **Services, maturité, dette** | `docs/catalogue-services.md` (**la carte de maturité + la cruft y sont déjà**) |
|
| **Services, maturité, dette** | `docs/catalogue-services.md` (**la carte de maturité + la cruft y sont déjà**) |
|
||||||
| **Exploitation / VM** | `docs/vm-lifecycle.md`, `docs/procedure-template-debian13-proxmox.md`, `docs/config-proxmox.md`, `docs/nomenclature-vm.md`, `docs/multi-instances.md` |
|
| **Exploitation / VM** | `docs/vm-lifecycle.md`, `docs/procedure-template-debian13-proxmox.md`, `docs/config-proxmox.md`, `docs/nomenclature-vm.md`, `docs/multi-instances.md` |
|
||||||
| **Conceptions de domaine** | `docs/identite-sso.md`, `docs/courriel-conception.md`, `docs/bindings-conception.md`, `docs/dns-interne.md`, `docs/dimensionnement-ressources.md`, `docs/integrations-vm.md` |
|
| **Conceptions de domaine** | `docs/identite-sso.md`, `docs/courriel-conception.md`, `docs/bindings-conception.md`, `docs/dns-interne.md`, `docs/dimensionnement-ressources.md`, `docs/integrations-vm.md` |
|
||||||
|
|
|
||||||
277
docs/conception-contextes.md
Normal file
277
docs/conception-contextes.md
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
# Contextes : un tronc commun, deux classes (SITE et LOCATAIRE)
|
||||||
|
|
||||||
|
> **Pour qui :** le **mainteneur** — comment le moteur sait s'il sert un site ou un locataire, et ce que les deux s'apprennent l'un à l'autre.
|
||||||
|
|
||||||
|
> **Statut : arrêtée avec l'exploitant le 2026-10-04.** Rien n'est encore construit ; les
|
||||||
|
> décisions sont au §7, le chemin au §6.
|
||||||
|
|
||||||
|
## 1. Le problème, mesuré
|
||||||
|
|
||||||
|
Le moteur ne sait pas dans quel contexte il tourne : **chaque script le devine**. Relevé du
|
||||||
|
2026-10-04 : **33 scripts** font leur propre déduction, à partir de cinq indices différents.
|
||||||
|
|
||||||
|
| Indice | Ce qu'on en déduit | Scripts |
|
||||||
|
|---|---|---|
|
||||||
|
| lien `instance/` ou `SETOPS_INSTANCE` | « un locataire est monté » | 23 |
|
||||||
|
| lien `underlay.yml` ou `SETOPS_UNDERLAY` | « un site est monté » ; son plan est à côté | 8 |
|
||||||
|
| `SETOPS_INVENTAIRE` | quel inventaire de locataire lire | 8 |
|
||||||
|
| `../*/plan/nomenclature.yml` | « la fédération », les locataires frères | 11 |
|
||||||
|
| `../SITE-*/underlay.yml` | « les sites » | 2 |
|
||||||
|
|
||||||
|
Les indices ne concordent pas toujours, et chaque désaccord a déjà produit un défaut silencieux :
|
||||||
|
|
||||||
|
- **2026-08-14** : `frontiere-plan` voulait poser sur la frontière de Technolibre les règles de
|
||||||
|
Chezlepro. « La fédération » valait « les locataires de ce site », jusqu'au second site.
|
||||||
|
- **2026-09-16** : la console du runner du site affichait zéro machine, sans erreur. Elle
|
||||||
|
cherchait un inventaire de locataire là où il n'y en a pas.
|
||||||
|
- **2026-10-04** : `make ci`, sur le poste, mélangeait le modèle public et les écosystèmes
|
||||||
|
réels. P74 lisait `SETOPS_UNDERLAY` (le modèle) ; P82 lisait le lien `underlay.yml` et les
|
||||||
|
dossiers frères (le site réel).
|
||||||
|
|
||||||
|
Les rôles Ansible, eux, ne posent pas ce problème : ils sont **déjà** le tronc commun. Un même
|
||||||
|
`serveur_postgresql` sert au site et chez un locataire.
|
||||||
|
|
||||||
|
## 2. Le modèle
|
||||||
|
|
||||||
|
```
|
||||||
|
Ecosysteme (tronc commun)
|
||||||
|
/ \
|
||||||
|
Site Locataire
|
||||||
|
\ /
|
||||||
|
`-- contrat --' (associations : un site A des locataires,
|
||||||
|
un locataire A un site)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.1 Le tronc commun : `Ecosysteme`
|
||||||
|
|
||||||
|
Ce que tout écosystème possède, quel que soit son contexte :
|
||||||
|
|
||||||
|
- un **nom** et un **dépôt** (`SITE-Chezlepro`, `OPS-Technolibre`) ;
|
||||||
|
- une **voûte** et sa clé (`~/.config/setops-vault-<dépôt>`) ;
|
||||||
|
- un **plan** (`<dépôt>/plan/`) et un **index**, dont dérive son adressage (le site
|
||||||
|
aussi depuis le 2026-09-20) ;
|
||||||
|
- des **machines**, déployées par les **mêmes rôles** : socle, durcissement, PKI, journaux,
|
||||||
|
métriques, supervision, sauvegarde de son propre état ;
|
||||||
|
- une **filiation** : le moteur et le commit dont il descend ;
|
||||||
|
- les **preuves communes** : lint, rendu des gabarits, adressage dérivé, etc.
|
||||||
|
|
||||||
|
Méthodes abstraites, que chaque classe **surcharge** : `inventaire()`, `machines()`,
|
||||||
|
`preuves()`, `verbes()`, `console()`.
|
||||||
|
|
||||||
|
### 2.2 `Site(Ecosysteme)`
|
||||||
|
|
||||||
|
- **Déclaration** : `underlay.yml` (le matériel, les réseaux `site` et `fabric`) et `plan/`
|
||||||
|
(`10-intrants.yml`, serveurs, applications, domaines, bases).
|
||||||
|
- **Inventaire** : dynamique (`site_inventaire.py`). Le site ne dérive rien d'un plan de services ;
|
||||||
|
sa déclaration est sa forme finale.
|
||||||
|
- **Ce qu'il porte en propre** : le matériel (hyperviseurs, commutateurs, frontière),
|
||||||
|
la matérialisation des VM (Proxmox), le SDN, le pare-feu Proxmox, la frontière OPNsense,
|
||||||
|
le DNS public, le dépôt des sauvegardes des locataires, le cache et les artefacts, la forge
|
||||||
|
du génome.
|
||||||
|
- **Relation** : `locataires()`, la liste de `underlay.tenants` résolue en objets
|
||||||
|
`Locataire`. Le site n'en lit que la **face réseau** (§2.4).
|
||||||
|
|
||||||
|
### 2.3 `Locataire(Ecosysteme)`
|
||||||
|
|
||||||
|
- **Déclaration** : `plan/` (nomenclature, serveurs, applications, bases, domaines).
|
||||||
|
- **Inventaire** : généré (`instancier.py` → `hosts.yml`). C'est la **méta-classe** de
|
||||||
|
[`meta-classe.md`](meta-classe.md) : une définition qui engendre toute la flotte.
|
||||||
|
- **Ce qu'il porte en propre** : la configuration de ses services, la remise au client.
|
||||||
|
- **Relation** : `site()`, l'hébergeur que nomme `parente.yml`, résolu en objet `Site`. Le
|
||||||
|
locataire n'en lit que les **intrants exposés** (§2.4).
|
||||||
|
|
||||||
|
### 2.4 Ce que le site et le locataire s'apprennent l'un à l'autre
|
||||||
|
|
||||||
|
Les deux entités **s'informent mutuellement**. Relevé du 2026-10-04 : qui décide de chaque
|
||||||
|
information, où elle vit, et comment elle parvient à l'autre.
|
||||||
|
|
||||||
|
**Ce que chacun a sous la main.** Le runner du site porte le moteur, son dépôt **et ceux de
|
||||||
|
ses locataires** (sans leurs voûtes). Le runner d'un locataire ne porte que le moteur et
|
||||||
|
**son propre** dépôt. Le poste porte tout.
|
||||||
|
|
||||||
|
#### Le site informe le locataire, par trois canaux
|
||||||
|
|
||||||
|
**Canal 1 : des copies écrites à la main** dans le dépôt du locataire.
|
||||||
|
|
||||||
|
| Information | Décidée par | Tenue chez le site dans | Copiée chez le locataire dans | Contrôle |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| son **index** | le site | `underlay.yml` → `tenants` | `plan/nomenclature.yml` (`index`) | `underlay valider` |
|
||||||
|
| son **adresse publique** | le site | `opnsense.yml` → `opnsense_ips_publiques` | `10-intrants.yml` (`ip_publique`) | — |
|
||||||
|
| les **10 intrants de service** : résolveur, cache, binaires, forge du génome, cible de sauvegarde, DNS public, plan d'administration, passerelle | le site (dérivés de son plan, par `site_intrants.py`) | son plan | `10-intrants.yml`, et `serveur_ops.yml` pour la forge | `site_intrants.py --verifier`, seulement là où les deux dépôts sont présents (le poste) |
|
||||||
|
| sa **racine de confiance** | le site | `ac-racine-site.crt` | le même fichier, copié | — |
|
||||||
|
| *hors contrat* : un dépôt de la forge du site désigné par son adresse | — | — | `serveur_web_dorsal.yml` (Chezlepro) | **aucun** |
|
||||||
|
|
||||||
|
**Canal 2 : une lecture directe, au moment de générer l'inventaire.** `instancier.py` ouvre
|
||||||
|
l'`underlay.yml` et le plan du site pour écrire le `hosts.yml` du locataire. Mesuré sur
|
||||||
|
Technolibre, inventaire généré avec puis sans le site monté : **quatre variables changent**.
|
||||||
|
|
||||||
|
| Variable du locataire | Avec le site monté | Sans le site |
|
||||||
|
|---|---|---|
|
||||||
|
| `chrony_serveurs` | `10.0.4.1` (la frontière) | absente |
|
||||||
|
| `proxmox_pont` | `t23appl` (le VNet SDN) | absente |
|
||||||
|
| `proxmox_etiquette_vlan` | aucune (le SDN étiquette) | `1236` |
|
||||||
|
| `serveur_resolveur_zones_deleguees` | `genese.internal` → `10.37.34.11` | absente |
|
||||||
|
|
||||||
|
**Canal 3 : le réseau.** Le runner du locataire **tire** son génome de la forge du site ; il est
|
||||||
|
né de l'**insémination** par le runner du site.
|
||||||
|
|
||||||
|
#### Le locataire informe le site : le site lit et recalcule
|
||||||
|
|
||||||
|
| Information | Décidée par | Tenue chez le locataire dans | Parvient au site par |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ses **zones** et son adressage | dérivés de l'index | `plan/nomenclature.yml` | le site **lit le fichier** |
|
||||||
|
| les **VM à matérialiser** | le locataire | `plan/serveurs.yml` → `hosts.yml` | le site **lit les fichiers** (placement, clonage, pools, SDN) |
|
||||||
|
| ses **flux** | ses rôles et son plan | `meta/flux.yml` des rôles (moteur), croisés avec son `hosts.yml` | le site **recalcule** lui-même, avec **sa** version du moteur et la totalité de l'inventaire du locataire → frontière, NAT, pare-feu Proxmox |
|
||||||
|
| ses **domaines publics** | le locataire | `plan/domaines.yml` (+ `applications.yml`, `serveurs.yml`) | le site **lit les fichiers** → DNS public secondaire |
|
||||||
|
| sa **clé de sauvegarde** | le locataire | `inventories/*/group_vars/serveur_backup.yml` | le site **lit le fichier** → compte Unix sur le dépôt |
|
||||||
|
| ses **accès d'administration** | le locataire | `plan/acces.yml`, `nftables_admin_ssh` | le site **lit les fichiers** → pairs WireGuard, règles d'administration |
|
||||||
|
|
||||||
|
Le SDN, lui, ne prend aucun flux : il ne filtre pas. Il ne reçoit que l'index, dont il dérive
|
||||||
|
la zone, les 6 VNets et les 6 sous-réseaux.
|
||||||
|
|
||||||
|
#### À l'exécution, entre machines
|
||||||
|
|
||||||
|
Ces échanges-là passent par le réseau, pas par les dépôts. Ils sont déjà déclarés en flux :
|
||||||
|
le locataire **dépose** ses sauvegardes chez le site (SFTP), **tire** ses paquets, ses binaires
|
||||||
|
et son génome, **entre** par le tunnel d'administration du site ; le site **réplique** les
|
||||||
|
zones publiques du locataire (AXFR signé TSIG).
|
||||||
|
|
||||||
|
#### Ce que le relevé montre
|
||||||
|
|
||||||
|
1. **Le site fouille l'intérieur du locataire.** Six fichiers de son plan et de son inventaire,
|
||||||
|
`group_vars` compris. Rien ne dit ce que le locataire **accepte** de montrer. Renommer un
|
||||||
|
champ chez le locataire casse le site sans bruit.
|
||||||
|
2. **Les flux sont calculés deux fois**, par le locataire pour ses `nftables` et par le site pour
|
||||||
|
la frontière et Proxmox, chacun avec **sa** version du moteur. Ils concordent tant que les deux
|
||||||
|
runners tiennent le même commit (c'était le cas le 2026-10-04), mais rien ne l'impose.
|
||||||
|
3. **Le locataire vit de copies** : douze valeurs et un certificat, recopiés à la main, plus une
|
||||||
|
valeur hors contrat. La garde qui compare ne tourne que sur le poste ; le runner du locataire
|
||||||
|
ne peut pas savoir que sa copie a vieilli.
|
||||||
|
4. **L'inventaire d'un locataire dépend du site monté au moment de le générer.** Généré sur le
|
||||||
|
runner du locataire, qui n'a pas le dépôt du site, il perdrait son serveur de temps, son SDN
|
||||||
|
et sa délégation DNS. Ça ne s'est jamais vu, parce que l'inventaire est toujours généré sur le
|
||||||
|
poste puis versionné.
|
||||||
|
5. **Deux décisions du site** (l'index, l'adresse publique) vivent en double.
|
||||||
|
|
||||||
|
#### Proposition : deux fiches, une dans chaque sens
|
||||||
|
|
||||||
|
Chacun **publie** ce qu'il donne à l'autre, dans une fiche **générée** par le moteur, jamais
|
||||||
|
écrite à la main. Chacun ne lit que la fiche que l'autre lui destine. Plus aucune lecture
|
||||||
|
croisée, plus aucun recalcul.
|
||||||
|
|
||||||
|
- **La fiche du site pour un locataire.** Tout ce que le site lui **attribue** (index, adresse
|
||||||
|
publique) et lui **offre** : les 10 intrants, sa racine de confiance, et ce que l'instancier
|
||||||
|
allait lire en douce (serveur de temps, délégation DNS, mode SDN et nom des VNets). Une fiche
|
||||||
|
**par locataire** : aucun ne voit le plan du site ni ses voisins. L'instancier ne lit plus que
|
||||||
|
cette fiche, et l'inventaire devient **identique où qu'on le génère**.
|
||||||
|
- **La face réseau du locataire.** Ce qu'il **demande** au site : VM à matérialiser, zones,
|
||||||
|
domaines publics, clé de sauvegarde publique, accès d'administration, et **ses flux déjà
|
||||||
|
résolus** (adresses, ports, protocoles), ceux avec l'extérieur pour la frontière et ceux de
|
||||||
|
chaque VM pour Proxmox. Le locataire génère déjà ses flux résolus (`flux-genere/*.nft`,
|
||||||
|
`*.connectivite.json`) : la face réseau en est la partie destinée au site. Le site ne
|
||||||
|
recalcule plus rien : il applique ce que le locataire publie, après l'avoir confronté à sa
|
||||||
|
propre politique.
|
||||||
|
- **Chaque fiche porte l'empreinte de sa source**, et une preuve de chaque côté vérifie que la
|
||||||
|
fiche reçue correspond à ce que l'autre a publié.
|
||||||
|
|
||||||
|
**Où en est l'étape 2 (2026-10-04).** La fiche du site (P84), les faits de la face réseau
|
||||||
|
(P85) et les flux de chaque machine (P86) existent, et disent exactement ce que les lectures
|
||||||
|
croisées produisent. La première mesure des flux a trouvé une information que le locataire
|
||||||
|
jetait : les clients nommés d'un port aussi public, que Proxmox doit admettre nommément. Il
|
||||||
|
les publie désormais (`sources_declarees`). La frontière, en trois temps : les identités
|
||||||
|
(P87), les entrées publiques (P88), l'administration (P89) et les sorties (P90) sont faites.
|
||||||
|
**L'étape 2 est terminée** (2026-10-05). Étape 3 : l'instancier lit la fiche déposée par le site, et
|
||||||
|
l'inventaire d'un locataire se génère sans son site, à l'octet près (P91). Le locataire publie sa face
|
||||||
|
réseau (P92) ; les comptes de sauvegarde, le DNS public, le pare-feu Proxmox, la frontière et la
|
||||||
|
découverte des locataires du site la lisent. La matérialisation aussi : la face publie les
|
||||||
|
paramètres de clonage (P93) ; `locataire-creer`, `locataire-raser` et `placement-plan TENANT=`
|
||||||
|
nomment leur locataire au lieu de le monter, et visent les mêmes machines, à l'argument près de
|
||||||
|
la ligne `ansible-playbook` (P94, `test_appels_locataire.py`) ; `reconstruire-locataire` les
|
||||||
|
emploie. Reste : la preuve par reconstruction.
|
||||||
|
|
||||||
|
Méthodes du contrat : `site.fiche_pour(locataire)`, `locataire.face_reseau()`. Une classe
|
||||||
|
n'ouvre jamais les fichiers de l'autre ; une preuve vérifiera la règle.
|
||||||
|
|
||||||
|
#### Ce que les fiches donnent : la portabilité
|
||||||
|
|
||||||
|
Un locataire qui change de site, pour un déménagement, un plan de reprise ou une émancipation,
|
||||||
|
n'a plus qu'à **recevoir la fiche de son nouveau site**. Son dépôt ne contient plus rien
|
||||||
|
d'interne à l'ancien : ni copie d'adresse, ni inventaire généré avec l'ancien site monté. En
|
||||||
|
face, le nouveau site n'a qu'à lire sa **face réseau**. Aujourd'hui, la même bascule demande de
|
||||||
|
corriger des copies dans plusieurs fichiers, puis de régénérer l'inventaire avec le nouveau site
|
||||||
|
monté sur le poste.
|
||||||
|
|
||||||
|
## 3. Le poste : un sélecteur
|
||||||
|
|
||||||
|
Aujourd'hui, le poste monte les deux contextes **en même temps** (`instance/` + `underlay.yml`),
|
||||||
|
et `ConsolePoste` hérite de `ConsoleLocataire`. Désormais :
|
||||||
|
|
||||||
|
- **Le poste choisit un contexte actif** : un site **ou** un locataire. La console ouvre celui-là,
|
||||||
|
et seulement celui-là.
|
||||||
|
- **Une opération qui traverse les deux** nomme ses objets au lieu de les deviner.
|
||||||
|
`reconstruire-locataire` en est l'exemple : le site matérialise, puis le locataire monte.
|
||||||
|
L'orchestration devient `site.materialiser(locataire)` puis `locataire.monter()`.
|
||||||
|
- **Les runners ne changent pas** : celui du site n'a qu'un `Site`, celui d'un locataire qu'un
|
||||||
|
`Locataire`. Leur contexte est désormais **dit**, plus déduit.
|
||||||
|
|
||||||
|
## 4. Qui surcharge quoi
|
||||||
|
|
||||||
|
| Méthode | Tronc commun | Site | Locataire |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `inventaire()` | abstraite | script dynamique (`underlay.yml`) | `hosts.yml` généré du plan |
|
||||||
|
| `machines()` | abstraite | VM du site + équipements | VM du plan |
|
||||||
|
| `adressage()` | dérivé de l'index | zones `site` dérivées, liens `fabric` écrits | 6 zones dérivées |
|
||||||
|
| `sauvegardes()` | son propre état, vérifié par restauration | + héberge les dépôts des locataires | dépose chez son site |
|
||||||
|
| `supervision()` | sondes déclarées par les rôles | + matériel, fabric, frontière | — |
|
||||||
|
| `raser()` / `reconstruire()` | — | `site_raser.py` | `raser.py`, `reconstruire_locataire.py` |
|
||||||
|
| `preuves()` | preuves communes | + preuves de site (P23, P74, P82…) | + preuves de locataire |
|
||||||
|
| `verbes()` | `verifier`, `publier`… | `site-*`, `frontiere-*`, `proxmox-*` | `appliquer`, `instancier`, `remise-*` |
|
||||||
|
| `console()` | — | console SITE | console LOCATAIRE |
|
||||||
|
|
||||||
|
## 5. Ce qui ne change pas
|
||||||
|
|
||||||
|
- Les **rôles Ansible**, déjà communs.
|
||||||
|
- Les **formats de plan** : pas dans ce chantier.
|
||||||
|
- La **doctrine** d'`AGENTS.md`.
|
||||||
|
|
||||||
|
## 6. Le chemin, chaque pas prouvé avant le suivant
|
||||||
|
|
||||||
|
1. **`scripts/contexte.py`** : `Ecosysteme`, `Site`, `Locataire`, `contexte_actif()`,
|
||||||
|
`Site.charger(nom)`, `Locataire.charger(nom)`. Tests unitaires. Rien ne l'utilise encore.
|
||||||
|
2. **Les deux fiches**, générées à côté de l'existant sans rien remplacer :
|
||||||
|
`site.fiche_pour(locataire)` et `locataire.face_reseau()`. Une preuve vérifie que chaque
|
||||||
|
fiche dit **exactement** ce que les lectures croisées d'aujourd'hui produisent.
|
||||||
|
3. **Les consommateurs basculent sur les fiches**, un par un : l'instancier sur la fiche du site
|
||||||
|
(l'inventaire généré doit rester identique, octet pour octet) ; la frontière, Proxmox, le DNS
|
||||||
|
public et les comptes de sauvegarde sur la face réseau (chaque devis doit rester inchangé).
|
||||||
|
4. **`prouver.py`** : chaque preuve déclare son contexte (commun, site, locataire) et reçoit son
|
||||||
|
écosystème du module.
|
||||||
|
5. **Les autres scripts**, un par un, vérifiés par `make verifier` et par un devis inchangé.
|
||||||
|
6. **Une preuve « aucune devinette, aucune lecture croisée »** : les indices du §1, et toute
|
||||||
|
ouverture d'un fichier de l'autre contexte, interdits hors de `contexte.py`.
|
||||||
|
7. **Les verbes du Makefile** rangés par contexte.
|
||||||
|
8. **Les consoles** : le sélecteur et deux consoles (chantier suivant).
|
||||||
|
9. **OPS-Modele** : un locataire modèle, et sans doute un site modèle, vérifiés chacun dans son
|
||||||
|
contexte.
|
||||||
|
|
||||||
|
Après les étapes 3 et 5, une reconstruction prouve que la flotte n'a pas bougé.
|
||||||
|
|
||||||
|
## 7. Décisions et questions ouvertes
|
||||||
|
|
||||||
|
### Tranché par l'exploitant
|
||||||
|
|
||||||
|
- **Deux classes, `Site` et `Locataire`, qui héritent d'un tronc commun** (2026-10-04).
|
||||||
|
- **Le poste est un sélecteur** : un contexte actif à la fois (2026-10-04).
|
||||||
|
- **On commence par le moteur**, les consoles viennent ensuite (2026-10-04).
|
||||||
|
- **Le site dépose sa fiche dans le dépôt du locataire** (2026-10-04), comme il y amorce déjà
|
||||||
|
son runner. Le runner du locataire n'a besoin d'aucun accès au dépôt du site, et ne voit
|
||||||
|
ni le plan du site ni ses voisins.
|
||||||
|
|
||||||
|
- **Le contexte actif se nomme dans un fichier `contexte` explicite** (2026-10-04), une seule
|
||||||
|
valeur : `site:SITE-Chezlepro` ou `locataire:OPS-Technolibre`. Un sélecteur qui monte deux
|
||||||
|
liens à la fois contredirait sa propre règle. Les liens `instance/` et `underlay.yml` restent
|
||||||
|
le temps de la bascule, lus par le seul `contexte.py`.
|
||||||
|
- **Un modèle SITE public, `SITE-Modele`**, à côté d'`OPS-Modele` (2026-10-04). Sans site, la
|
||||||
|
CI ne peut exercer ni les preuves de site (P74, P81, P82) ni la fiche que le site dépose chez
|
||||||
|
le locataire.
|
||||||
|
- **Le site a aussi son `parente.yml`** (2026-10-04) : la filiation est dans le tronc commun.
|
||||||
|
|
@ -30,7 +30,7 @@ make placement-plan # chaque VM est-elle là où le plan la met
|
||||||
|
|
||||||
## Le trou qu'il comble
|
## Le trou qu'il comble
|
||||||
|
|
||||||
`scripts/prouver.py` porte 83 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le
|
`scripts/prouver.py` porte 94 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le
|
||||||
dépôt. Zéro appel réseau, zéro SSH, zéro `ansible`. Elles établissent que le dépôt est
|
dépôt. Zéro appel réseau, zéro SSH, zéro `ansible`. Elles établissent que le dépôt est
|
||||||
cohérent **avec lui-même** — que les handlers existent, que les intrants ont un
|
cohérent **avec lui-même** — que les handlers existent, que les intrants ont un
|
||||||
propriétaire, que rien n'est codé en dur.
|
propriétaire, que rien n'est codé en dur.
|
||||||
|
|
|
||||||
|
|
@ -74,8 +74,7 @@
|
||||||
| `serveur_nextcloud` | egress | 443 | tcp | edge | tls-requis | Découverte OIDC auprès de Keycloak (via son FQDN publié à l'edge). |
|
| `serveur_nextcloud` | egress | 443 | tcp | edge | tls-requis | Découverte OIDC auprès de Keycloak (via son FQDN publié à l'edge). |
|
||||||
| `serveur_nextcloud` | egress | 5432 | tcp | serveur_postgresql | tls-requis | Base de données Nextcloud (verify-full). |
|
| `serveur_nextcloud` | egress | 5432 | tcp | serveur_postgresql | tls-requis | Base de données Nextcloud (verify-full). |
|
||||||
| `serveur_nextcloud` | egress | 9980 | tcp | serveur_collabora | tls-cible | Vérifications WOPI serveur->Collabora (édition en ligne). TLS interne = feuille de route edge->backends. |
|
| `serveur_nextcloud` | egress | 9980 | tcp | serveur_collabora | tls-cible | Vérifications WOPI serveur->Collabora (édition en ligne). TLS interne = feuille de route edge->backends. |
|
||||||
| `serveur_nginx` | ingress | 80 | tcp | externe | clair | HTTP entrant — redirection permanente vers HTTPS. |
|
| `serveur_nginx` | ingress | 80 | tcp | admin | clair | HTTP depuis le reseau d'administration — redirection permanente vers HTTPS. |
|
||||||
| `serveur_nginx` | ingress | 443 | tcp | externe | tls-requis | HTTPS entrant — services exposés (terminaison TLS à l'edge). |
|
|
||||||
| `serveur_nginx` | ingress | 443 | tcp | flotte | tls-requis | HTTPS depuis le tenant : les FQDN publiés vivent à l'edge (découverte OIDC, appels inter-services par nom). |
|
| `serveur_nginx` | ingress | 443 | tcp | flotte | tls-requis | HTTPS depuis le tenant : les FQDN publiés vivent à l'edge (découverte OIDC, appels inter-services par nom). |
|
||||||
| `serveur_nginx` | ingress | 443 | tcp | admin | tls-requis | HTTPS depuis le reseau d'administration : l'exploitant administre les services par leur interface web, servie par l'edge. |
|
| `serveur_nginx` | ingress | 443 | tcp | admin | tls-requis | HTTPS depuis le reseau d'administration : l'exploitant administre les services par leur interface web, servie par l'edge. |
|
||||||
| `serveur_nginx` | egress | derive | tcp | expositions | tls-cible | Proxy vers les backends exposés (host:port dérivés des expose ; TLS interne = roadmap edge→backends). |
|
| `serveur_nginx` | egress | derive | tcp | expositions | tls-cible | Proxy vers les backends exposés (host:port dérivés des expose ; TLS interne = roadmap edge→backends). |
|
||||||
|
|
@ -87,15 +86,14 @@
|
||||||
| `serveur_ops` | ingress | 8090 | tcp | edge, admin | clair | Console d'exploitation servie par l'edge (TLS terminé à l'edge), et joignable depuis le plan d'administration là où il n'y a pas d'edge. Le GUI lui-même reste sur la boucle locale : c'est nginx qui authentifie devant. |
|
| `serveur_ops` | ingress | 8090 | tcp | edge, admin | clair | Console d'exploitation servie par l'edge (TLS terminé à l'edge), et joignable depuis le plan d'administration là où il n'y a pas d'edge. Le GUI lui-même reste sur la boucle locale : c'est nginx qui authentifie devant. |
|
||||||
| `serveur_ops` | egress | 22 | tcp | flotte | ssh | Piloter la flotte — c'est la raison d'être du poste. |
|
| `serveur_ops` | egress | 22 | tcp | flotte | ssh | Piloter la flotte — c'est la raison d'être du poste. |
|
||||||
| `serveur_ops` | egress | 443 | tcp | edge | tls-requis | Cloner et resynchroniser le génome depuis la forge de l'écosystème. |
|
| `serveur_ops` | egress | 443 | tcp | edge | tls-requis | Cloner et resynchroniser le génome depuis la forge de l'écosystème. |
|
||||||
| `serveur_ops` | egress | 443 | tcp | voisins_site | tls-requis | Cloner le genome depuis la forge du site, quand cet ecosysteme n'heberge pas la sienne. |
|
|
||||||
| `serveur_ops` | egress | 8006 | tcp | externe | tls-requis | API de l'hyperviseur : créer et cloner les VM d'un écosystème descendant. |
|
|
||||||
| `serveur_ops_site` | egress | 22 | tcp | fabric | ssh | Shell des hyperviseurs : ce que l'API ne couvre pas — configuration reseau, ponts, deplacement de disques. Un pouvoir distinct de l'API, donc declare a part. |
|
| `serveur_ops_site` | egress | 22 | tcp | fabric | ssh | Shell des hyperviseurs : ce que l'API ne couvre pas — configuration reseau, ponts, deplacement de disques. Un pouvoir distinct de l'API, donc declare a part. |
|
||||||
| `serveur_ops_site` | egress | 22 | tcp | serveur_ops_tenant | ssh | Insemination : amorcer le runner d'un tenant — socle, moteur, plan, plancher de resolution — pour qu'il prenne ensuite le relais sur ses propres machines. Ne transporte aucun secret : la voute est remise par un humain. |
|
| `serveur_ops_site` | egress | 22 | tcp | serveur_ops_tenant | ssh | Insemination : amorcer le runner d'un tenant — socle, moteur, plan, plancher de resolution — pour qu'il prenne ensuite le relais sur ses propres machines. Ne transporte aucun secret : la voute est remise par un humain. |
|
||||||
| `serveur_ops_site` | egress | 443 | tcp | fabric | tls-requis | API de la frontiere OPNsense : poser les alias et les regles qui ouvrent les flux du tenant qu'on materialise. Preparer le terrain sans cela laisserait un terrain injoignable. |
|
| `serveur_ops_site` | egress | 443 | tcp | fabric | tls-requis | API de la frontiere OPNsense : poser les alias et les regles qui ouvrent les flux du tenant qu'on materialise. Preparer le terrain sans cela laisserait un terrain injoignable. |
|
||||||
| `serveur_ops_site` | egress | 8006 | tcp | fabric | tls-requis | API de l'hyperviseur : créer, cloner et détruire les VM de la fabric. Le seul flux par lequel un écosystème peut en matérialiser un autre. |
|
| `serveur_ops_site` | egress | 8006 | tcp | fabric | tls-requis | API de l'hyperviseur : créer, cloner et détruire les VM de la fabric. Le seul flux par lequel un écosystème peut en matérialiser un autre. |
|
||||||
| `serveur_ops_tenant` | ingress | 22 | tcp | runner_site | ssh | Insémination : le runner du SITE amorce ce runner-ci — socle, moteur, plan, plancher de résolution — jusqu'à ce qu'un humain lui remette sa voûte. Vers cette machine seule, jamais vers le reste de l'écosystème. |
|
| `serveur_ops_tenant` | ingress | 22 | tcp | runner_site | ssh | Insémination : le runner du SITE amorce ce runner-ci — socle, moteur, plan, plancher de résolution — jusqu'à ce qu'un humain lui remette sa voûte. Vers cette machine seule, jamais vers le reste de l'écosystème. |
|
||||||
| `serveur_postfix` | ingress | 25 | tcp | externe, client_smtp | starttls | SMTP entrant : courrier externe (MX) et notifications internes (client_smtp). |
|
| `serveur_postfix` | ingress | 25 | tcp | externe, client_smtp | starttls | SMTP entrant : courrier externe (MX) et notifications internes (client_smtp). |
|
||||||
| `serveur_postfix` | ingress | 587 | tcp | flotte | starttls | Soumission authentifiée (submission) pour les agents internes qui envoient du courrier. |
|
| `serveur_postfix` | ingress | 465 | tcp | flotte, externe | tls-requis | Soumission authentifiée en TLS direct (submissions, RFC 8314) : clients de courriel des utilisateurs. |
|
||||||
|
| `serveur_postfix` | ingress | 587 | tcp | flotte, externe | starttls | Soumission authentifiée (STARTTLS obligatoire) : agents internes et clients de courriel des utilisateurs. |
|
||||||
| `serveur_postfix` | egress | 24 | tcp | serveur_dovecot | tls-requis | Remise finale par LMTP au mailstore (Dovecot), en TLS vérifié. |
|
| `serveur_postfix` | egress | 24 | tcp | serveur_dovecot | tls-requis | Remise finale par LMTP au mailstore (Dovecot), en TLS vérifié. |
|
||||||
| `serveur_postfix` | egress | 25 | tcp | externe | starttls | Relais sortant vers les MX distants (STARTTLS opportuniste). |
|
| `serveur_postfix` | egress | 25 | tcp | externe | starttls | Relais sortant vers les MX distants (STARTTLS opportuniste). |
|
||||||
| `serveur_postfix` | egress | 636 | tcp | serveur_openldap | tls-requis | Tables virtuelles (domaines/alias/boîtes) résolues sur l'annuaire (LDAPS). |
|
| `serveur_postfix` | egress | 636 | tcp | serveur_openldap | tls-requis | Tables virtuelles (domaines/alias/boîtes) résolues sur l'annuaire (LDAPS). |
|
||||||
|
|
@ -122,17 +120,18 @@
|
||||||
| `serveur_rspamd` | ingress | 11332 | tcp | localhost | clair | Protocole milter consommé par Postfix co-localisé (analyse + signature DKIM). Local uniquement. |
|
| `serveur_rspamd` | ingress | 11332 | tcp | localhost | clair | Protocole milter consommé par Postfix co-localisé (analyse + signature DKIM). Local uniquement. |
|
||||||
| `serveur_rspamd` | ingress | 11334 | tcp | localhost | clair | Interface de contrôle rspamd (statistiques, apprentissage) en local. |
|
| `serveur_rspamd` | ingress | 11334 | tcp | localhost | clair | Interface de contrôle rspamd (statistiques, apprentissage) en local. |
|
||||||
| `serveur_step_ca` | ingress | 8443 | tcp | flotte | tls-requis | ACME + API step-ca : chaque nœud (client_pki) émet/renouvelle ses certificats et récupère la racine. |
|
| `serveur_step_ca` | ingress | 8443 | tcp | flotte | tls-requis | ACME + API step-ca : chaque nœud (client_pki) émet/renouvelle ses certificats et récupère la racine. |
|
||||||
| `serveur_web_dorsal` | ingress | 80 | tcp | edge | clair | Front nginx local des webapps, proxie par l'edge (TLS termine a l'edge). |
|
| `serveur_web_dorsal` | ingress | 80 | tcp | edge, serveur_web_frontal | clair | Front nginx local des webapps et des sites statiques, relaye par l'edge (noms internes) et par le web frontal (noms publics). |
|
||||||
| `serveur_web_dorsal` | egress | 443 | tcp | flotte | tls | git clone/pull du depot de chaque app (Forgejo souverain) au deploiement. |
|
| `serveur_web_dorsal` | egress | 443 | tcp | flotte | tls | git clone/pull du depot de chaque app (Forgejo souverain) au deploiement. |
|
||||||
| `serveur_web_frontal` | ingress | 80 | tcp | edge | clair | Contenu statique servi au navigateur via l'edge (TLS termine a l'edge). |
|
| `serveur_web_frontal` | ingress | 80 | tcp | externe | clair | HTTP public, relaye a travers le WAF — et le defi HTTP d'ACME ; renverra vers HTTPS quand le certificat sera la. |
|
||||||
| `serveur_web_frontal` | egress | 443 | tcp | flotte | tls | git clone/pull du depot du site (Forgejo souverain) au deploiement. |
|
| `serveur_web_frontal` | ingress | 443 | tcp | externe | tls-requis | HTTPS public — les sites et services que le locataire publie sur l'Internet (attend son certificat Let's Encrypt). |
|
||||||
|
| `serveur_web_frontal` | egress | 80 | tcp | serveur_web_dorsal | clair | Relais vers le web dorsal, qui porte les sites et les applications publiques. |
|
||||||
|
|
||||||
## Synthèse chiffrement
|
## Synthèse chiffrement
|
||||||
|
|
||||||
- **clair** : 50 flux
|
- **clair** : 51 flux
|
||||||
- **n-a** : 8 flux
|
- **n-a** : 8 flux
|
||||||
- **ssh** : 8 flux
|
- **ssh** : 8 flux
|
||||||
- **starttls** : 6 flux
|
- **starttls** : 6 flux
|
||||||
- **tls** : 11 flux
|
- **tls** : 10 flux
|
||||||
- **tls-cible** : 2 flux
|
- **tls-cible** : 2 flux
|
||||||
- **tls-requis** : 35 flux
|
- **tls-requis** : 34 flux
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ Nommer un point ouvert vaut mieux qu'une ligne rassurante sans mécanisme derri
|
||||||
Aucune de ces responsabilités n'est laissée à la parole :
|
Aucune de ces responsabilités n'est laissée à la parole :
|
||||||
|
|
||||||
```
|
```
|
||||||
make prouver le dépôt est-il cohérent avec lui-même (83 preuves, zéro réseau)
|
make prouver le dépôt est-il cohérent avec lui-même (94 preuves, zéro réseau)
|
||||||
make remise-verifier le second temps de la remise est-il fait, ou en retard ? (P80)
|
make remise-verifier le second temps de la remise est-il fait, ou en retard ? (P80)
|
||||||
make certificats-plan ce que le disque porte contre ce que la mémoire sert
|
make certificats-plan ce que le disque porte contre ce que la mémoire sert
|
||||||
make expositions-plan chaque service publié répond-il, et depuis où
|
make expositions-plan chaque service publié répond-il, et depuis où
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,20 @@ variables:
|
||||||
DANS:
|
DANS:
|
||||||
invite: "Jours avant le second temps"
|
invite: "Jours avant le second temps"
|
||||||
facultatif: true
|
facultatif: true
|
||||||
|
JEU:
|
||||||
|
invite: "Le jeu d'etat (openldap, postgresql, nextcloud... — voir restauration-etat)"
|
||||||
|
DEPUIS:
|
||||||
|
invite: "Reprendre a cette etape (vide = depuis le debut)"
|
||||||
|
valeurs: [sauvegarder, raser, creer, inseminer, armer, monter, parefeu, bilan]
|
||||||
|
facultatif: true
|
||||||
|
ARMER:
|
||||||
|
invite: "Armer sans marquer la pause"
|
||||||
|
valeurs: [oui]
|
||||||
|
facultatif: true
|
||||||
|
SOURCE:
|
||||||
|
invite: "Comparer a quel instantane (vide = celui d'avant rasage)"
|
||||||
|
valeurs: [candidat]
|
||||||
|
facultatif: true
|
||||||
VMID:
|
VMID:
|
||||||
invite: "Identifiant Proxmox de la VM"
|
invite: "Identifiant Proxmox de la VM"
|
||||||
DIALECTE:
|
DIALECTE:
|
||||||
|
|
@ -199,6 +213,12 @@ runbooks:
|
||||||
- cible: site-intrants
|
- cible: site-intrants
|
||||||
nature: mesure
|
nature: mesure
|
||||||
pourquoi: "Ce que ce site expose a ses locataires, derive et non declare deux fois."
|
pourquoi: "Ce que ce site expose a ses locataires, derive et non declare deux fois."
|
||||||
|
- cible: fiches-site-deposer
|
||||||
|
nature: ecriture
|
||||||
|
pourquoi: >-
|
||||||
|
Deposer chez chaque locataire la fiche que le site lui destine. Son runner n'a pas
|
||||||
|
le depot du site : c'est par elle que son inventaire se genere sans lui. A commiter
|
||||||
|
dans le depot de chaque locataire.
|
||||||
- cible: site-verifier
|
- cible: site-verifier
|
||||||
nature: mesure
|
nature: mesure
|
||||||
pourquoi: "Le playbook du site correspond-il encore aux couches declarees ?"
|
pourquoi: "Le playbook du site correspond-il encore aux couches declarees ?"
|
||||||
|
|
@ -208,6 +228,21 @@ runbooks:
|
||||||
- cible: routes-fabric-etat
|
- cible: routes-fabric-etat
|
||||||
nature: mesure
|
nature: mesure
|
||||||
pourquoi: "Une route de zone manquante est invisible jusqu'au premier invite qui la traverse."
|
pourquoi: "Une route de zone manquante est invisible jusqu'au premier invite qui la traverse."
|
||||||
|
- cible: locataire-creer
|
||||||
|
nature: ecriture
|
||||||
|
variables: [TENANT, PARALLELE]
|
||||||
|
fixes: {CONFIRMER: "true"}
|
||||||
|
pourquoi: >-
|
||||||
|
Cloner les VM d'un locataire NOMME, d'apres la face reseau qu'il publie : le
|
||||||
|
runner du site materialise sans monter son depot. Memes machines, memes
|
||||||
|
parametres que `flotte-creer` sur l'instance montee (P93, P94).
|
||||||
|
- cible: locataire-raser
|
||||||
|
nature: destructif
|
||||||
|
variables: [TENANT, INSTANCE]
|
||||||
|
fixes: {CONFIRMER: "true"}
|
||||||
|
pourquoi: >-
|
||||||
|
Detruire les VM d'un locataire NOMME, d'apres sa face : les memes VMID que le
|
||||||
|
plan derive (P94). Son nom court s'ecrit en toutes lettres, comme pour `raser`.
|
||||||
- cible: site-raser
|
- cible: site-raser
|
||||||
nature: destructif
|
nature: destructif
|
||||||
variables: [SITE]
|
variables: [SITE]
|
||||||
|
|
@ -310,6 +345,12 @@ runbooks:
|
||||||
pourquoi: >-
|
pourquoi: >-
|
||||||
Regenerer le registre des flux et les regles nftables depuis les `meta/flux.yml`
|
Regenerer le registre des flux et les regles nftables depuis les `meta/flux.yml`
|
||||||
des roles. Tout ce qui suit en descend.
|
des roles. Tout ce qui suit en descend.
|
||||||
|
- cible: face-reseau-publier
|
||||||
|
nature: ecriture
|
||||||
|
pourquoi: >-
|
||||||
|
Publier ce que ce locataire demande a son site (`face-reseau.yml`) : ses machines,
|
||||||
|
ses zones, ses flux deja resolus. Le site ne lit plus que ce fichier. Apres `flux`,
|
||||||
|
et a commiter dans le depot du locataire.
|
||||||
- cible: devis-proxmox-fw
|
- cible: devis-proxmox-fw
|
||||||
nature: mesure
|
nature: mesure
|
||||||
pourquoi: "Le pare-feu est-ouest intra-locataire, derive du registre des flux."
|
pourquoi: "Le pare-feu est-ouest intra-locataire, derive du registre des flux."
|
||||||
|
|
@ -380,6 +421,14 @@ runbooks:
|
||||||
pourquoi: >-
|
pourquoi: >-
|
||||||
Les VM manquantes, clonees depuis le gabarit dore. VMID, IP et VLAN sont DERIVES
|
Les VM manquantes, clonees depuis le gabarit dore. VMID, IP et VLAN sont DERIVES
|
||||||
du plan : on ne les saisit nulle part.
|
du plan : on ne les saisit nulle part.
|
||||||
|
- cible: monter-flotte
|
||||||
|
nature: ecriture
|
||||||
|
duree: "long"
|
||||||
|
fixes: {CONFIRMER: "true"}
|
||||||
|
pourquoi: >-
|
||||||
|
Des VM qui viennent de naitre (ou de renaitre) a la flotte recettee : flux, AC et
|
||||||
|
DNS d'abord, tout le reste, puis `valider`. C'est la sequence du runner apres une
|
||||||
|
reconstruction ; chaque proprietaire d'etat y remet celui d'avant.
|
||||||
- cible: deployer-tout
|
- cible: deployer-tout
|
||||||
nature: ecriture
|
nature: ecriture
|
||||||
duree: "long"
|
duree: "long"
|
||||||
|
|
@ -458,6 +507,24 @@ runbooks:
|
||||||
La preuve la plus dure du moteur : detruire un ecosysteme et le refaire depuis le
|
La preuve la plus dure du moteur : detruire un ecosysteme et le refaire depuis le
|
||||||
code seul. A ne lancer que sur un chantier — la prod vit ailleurs.
|
code seul. A ne lancer que sur un chantier — la prod vit ailleurs.
|
||||||
etapes:
|
etapes:
|
||||||
|
- cible: reconstruire-locataire
|
||||||
|
nature: destructif
|
||||||
|
portee: poste
|
||||||
|
duree: "long"
|
||||||
|
variables: [TENANT, DEPUIS, ARMER]
|
||||||
|
fixes: {CONFIRMER: "true"}
|
||||||
|
pourquoi: >-
|
||||||
|
TOUT, d'une commande, depuis le poste : sauvegarder, raser et recreer (runner du
|
||||||
|
site), amorcer, armer, monter (runner du locataire, etat remis), pare-feu, bilan.
|
||||||
|
Arret a la premiere etape en echec ; `DEPUIS` reprend. Les etapes ci-dessous sont
|
||||||
|
les memes, une a une.
|
||||||
|
- cible: sauvegarder-maintenant
|
||||||
|
nature: ecriture
|
||||||
|
pourquoi: >-
|
||||||
|
Deposer l'etat de chaque noeud JUSTE AVANT de raser. La reconstruction remet le
|
||||||
|
dernier instantane anterieur a la naissance des machines : sans ce depot, c'est
|
||||||
|
celui de la nuit, et la journee est perdue. Il est etiquete « avant-raser » et
|
||||||
|
garde jusqu'a la reconstruction suivante : c'est contre lui que jugent les temoins.
|
||||||
- cible: raser
|
- cible: raser
|
||||||
nature: destructif
|
nature: destructif
|
||||||
portee: poste
|
portee: poste
|
||||||
|
|
@ -472,11 +539,47 @@ runbooks:
|
||||||
duree: "long"
|
duree: "long"
|
||||||
fixes: {CONFIRMER: "true"}
|
fixes: {CONFIRMER: "true"}
|
||||||
pourquoi: >-
|
pourquoi: >-
|
||||||
Refaire tout depuis zero : les VM, puis le deploiement complet. Si le code ne
|
Refaire tout depuis zero : les VM, puis le deploiement complet — ou chaque role
|
||||||
suffit pas, c'est ici qu'on l'apprend.
|
proprietaire REMET l'etat de l'incarnation precedente (AC, annuaire, bases,
|
||||||
|
Nextcloud, courriel, DKIM). Si le code ne suffit pas, c'est ici qu'on l'apprend.
|
||||||
|
- cible: restauration-etat
|
||||||
|
nature: mesure
|
||||||
|
pourquoi: >-
|
||||||
|
Ce que chaque jeu est devenu : restaure (et depuis quel instantane), neuf, en
|
||||||
|
place. Un jeu EN ATTENTE bloque la sauvegarde de son noeud — c'est voulu.
|
||||||
|
- cible: temoins-etat
|
||||||
|
nature: mesure
|
||||||
|
variables: [HOTE, SOURCE]
|
||||||
|
pourquoi: >-
|
||||||
|
L'etat vivant est-il celui d'avant le rasage ? Une perte, ou une identite changee
|
||||||
|
(cles de l'AC, DKIM, instanceid de Nextcloud, mot de passe d'une entree de
|
||||||
|
l'annuaire), est un ecart. Le bilan dit ce que les roles ont fait ; les temoins
|
||||||
|
mesurent ce qui est revenu.
|
||||||
|
- cible: restauration-renoncer
|
||||||
|
nature: destructif
|
||||||
|
variables: [HOTE, JEU]
|
||||||
|
fixes: {CONFIRMER: "true"}
|
||||||
|
pourquoi: >-
|
||||||
|
Ecarter l'etat d'avant d'un jeu, sans le remettre. La sauvegarde reprend, et
|
||||||
|
l'ancien etat sortira de la retention : une decision, pas une reparation.
|
||||||
- cible: valider
|
- cible: valider
|
||||||
nature: mesure
|
nature: mesure
|
||||||
pourquoi: "Une reconstruction sans recette n'a rien prouve."
|
pourquoi: "Une reconstruction sans recette n'a rien prouve."
|
||||||
|
- cible: parefeu-verifier-flotte
|
||||||
|
nature: mesure
|
||||||
|
portee: poste
|
||||||
|
variables: [INSTANCE]
|
||||||
|
pourquoi: >-
|
||||||
|
La sonde `connectivite` est-elle saine sur chaque VM ? C'est le prealable : une VM
|
||||||
|
clonee nait sans ses options de pare-feu, et on n'active que sur une flotte saine.
|
||||||
|
- cible: parefeu-activer-flotte
|
||||||
|
nature: ecriture
|
||||||
|
portee: poste
|
||||||
|
variables: [INSTANCE]
|
||||||
|
fixes: {CONFIRMER: "true"}
|
||||||
|
pourquoi: >-
|
||||||
|
Tout activer d'un coup, puis lire la sonde `connectivite` que chaque VM rapporte a
|
||||||
|
la minute : seul ce qui change apres l'activation compte. Environ trois minutes.
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
- id: gabarit
|
- id: gabarit
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,64 @@ Note : ne s'applique qu'aux Forgejo **gérées par Set-OPS**.
|
||||||
|
|
||||||
## 5. Restaurer — et d'abord : prouver qu'on peut
|
## 5. Restaurer — et d'abord : prouver qu'on peut
|
||||||
|
|
||||||
|
### 5.0 La reconstruction remet l'état (depuis le 2026-09-30)
|
||||||
|
|
||||||
|
Jusqu'au 2026-09-30, **une reconstruction repartait d'un état neuf** : nouvelle racine
|
||||||
|
d'AC, annuaire vierge (`sysadmin` au mot de passe d'amorçage), bases, Nextcloud et courriel
|
||||||
|
vides. Les instantanés se restauraient pour *prouver* qu'ils s'ouvrent, jamais pour être
|
||||||
|
remis en service.
|
||||||
|
|
||||||
|
Désormais **chaque rôle propriétaire remet l'état de l'incarnation précédente**, au moment
|
||||||
|
où il le créerait neuf — la bonne séquence vient des couches de déploiement :
|
||||||
|
|
||||||
|
| Rôle | Quand | Condition « vierge » |
|
||||||
|
|---|---|---|
|
||||||
|
| `serveur_step_ca` | avant `step ca init` | pas de `ca.json` — et la voûte doit ouvrir les clés restaurées |
|
||||||
|
| `serveur_postgresql` | juste après la création des bases | base sans table (hors `nextcloud`, `icinga`) |
|
||||||
|
| `serveur_openldap` | en fin de rôle (schéma et `ppolicy` chargés) | aucune entrée hors la racine |
|
||||||
|
| `serveur_dovecot` | après le répertoire des boîtes | répertoire vide |
|
||||||
|
| `serveur_rspamd` | avant la génération DKIM | pas de clé DKIM |
|
||||||
|
| `serveur_forgejo` | avant l'arborescence de données | répertoire vide |
|
||||||
|
| `serveur_nextcloud` | **en fin de rôle** : base + fichiers + config ensemble, puis `occ upgrade` | pas de `config.php` au départ, base vide |
|
||||||
|
| `serveur_web_dorsal` | après sa racine | racine vide |
|
||||||
|
|
||||||
|
**L'instantané remis** est le dernier pris **avant la naissance de la machine** (date de
|
||||||
|
sa clé d'hôte SSH). Un état en place n'est jamais écrasé d'office. Chaque jeu laisse un
|
||||||
|
marqueur dans `/etc/setops/restauration/<jeu>` ; ce qui a été remplacé est mis de côté dans
|
||||||
|
`/var/backups/setops-avant-restauration/`.
|
||||||
|
|
||||||
|
**La sauvegarde refuse de déposer** (code 3) tant qu'un état d'avant n'a été ni remis ni
|
||||||
|
écarté : sinon `restic forget --keep-daily` chasserait l'instantané d'avant au profit de
|
||||||
|
l'état neuf du même jour.
|
||||||
|
|
||||||
|
**L'instantané d'avant rasage est étiqueté** `avant-raser` (depuis le 2026-10-07) et la
|
||||||
|
rétention le garde **jusqu'à la reconstruction suivante**, qui lui retire l'étiquette. Sans
|
||||||
|
elle, le premier dépôt de la machine reconstruite le chassait le jour même : après la
|
||||||
|
reconstruction de Technolibre (M4), il ne restait rien à quoi comparer l'état remis.
|
||||||
|
|
||||||
|
**Les témoins** comparent l'état vivant à cet instantané. Un écart, c'est une **perte**
|
||||||
|
(fichier, courriel, entrée de l'annuaire, base, rôle, table, ligne d'historique, ou une
|
||||||
|
base dont plus aucune ligne d'avant ne subsiste) ou une **identité changée** : clés et
|
||||||
|
certificats de l'AC, AC d'Icinga et environnement d'Icinga DB, clé DKIM, `instanceid` de
|
||||||
|
Nextcloud, mot de passe d'une entrée de l'annuaire. PostgreSQL se compare par clé (première
|
||||||
|
colonne de chaque table), pas par nombre de lignes. Le reste (un courriel reçu depuis, une table de sessions, le bayes de rspamd,
|
||||||
|
un cache) est listé sans être un écart. La reconstruction les lance à son étape `bilan`.
|
||||||
|
|
||||||
|
Les gestes :
|
||||||
|
|
||||||
|
```
|
||||||
|
make sauvegarder-maintenant # JUSTE AVANT de raser : étiqueté « avant-raser »
|
||||||
|
make restauration-etat [HOTE=...] # ce que chaque jeu est devenu, et si son instantané est encore au dépôt
|
||||||
|
make temoins-etat [HOTE=...] # l'état vivant contre l'instantané d'avant rasage
|
||||||
|
make temoins-etat SOURCE=candidat # faute d'étiquette : contre le dernier d'avant la naissance
|
||||||
|
make restauration-renoncer HOTE=.. JEU=.. CONFIRMER=true # écarter un état, sans le remettre
|
||||||
|
-e client_backup_restauration_instantane=<id> # imposer un instantané, par hôte
|
||||||
|
```
|
||||||
|
|
||||||
|
Sur un nœud, sans Ansible : `setops-restaurer etat`, `setops-restaurer temoins`, et les répétitions qui ne touchent à
|
||||||
|
rien — `setops-restaurer annuaire --essai <base_dn>`, `base <nom> --vers epreuve`,
|
||||||
|
`fichiers --vers /var/tmp/epreuve <chemin>`.
|
||||||
|
|
||||||
> Éprouvé le 2026-08-12 sur Chezlepro. `make valider` rejoue la partie automatisable ;
|
> Éprouvé le 2026-08-12 sur Chezlepro. `make valider` rejoue la partie automatisable ;
|
||||||
> la restauration d'une base reste manuelle, et porte un piège décrit plus bas.
|
> la restauration d'une base reste manuelle, et porte un piège décrit plus bas.
|
||||||
|
|
||||||
|
|
@ -143,10 +201,11 @@ la base badger de step-ca, qui avance à **chaque** émission de certificat.
|
||||||
|
|
||||||
`pg_dumpall` écrit `CREATE DATABASE <suivante>` **avant** le `\connect` correspondant.
|
`pg_dumpall` écrit `CREATE DATABASE <suivante>` **avant** le `\connect` correspondant.
|
||||||
Découper « du `\connect X` au `\connect` suivant » emporte donc un ordre qui vise une
|
Découper « du `\connect X` au `\connect` suivant » emporte donc un ordre qui vise une
|
||||||
**autre** base. Couper aussi sur `CREATE DATABASE`, et **vérifier avant de rejouer** :
|
**autre** base. Couper aussi sur `CREATE DATABASE` et sur `DROP DATABASE` (avec `--clean`,
|
||||||
|
la section de `nextcloud` finissait par `DROP DATABASE postgres;` — 2026-09-30), et **vérifier avant de rejouer** :
|
||||||
|
|
||||||
```
|
```
|
||||||
awk '/^\\connect forgejo$/{f=1;next} f && (/^\\connect /||/^CREATE DATABASE /){exit} f' \
|
awk '/^\\connect forgejo$/{f=1;next} f && (/^\\connect /||/^CREATE DATABASE /||/^DROP DATABASE /){exit} f' \
|
||||||
toutes-bases.sql > section.sql
|
toutes-bases.sql > section.sql
|
||||||
|
|
||||||
grep -qE '^(DROP|CREATE|ALTER) DATABASE|^\\connect' section.sql \
|
grep -qE '^(DROP|CREATE|ALTER) DATABASE|^\\connect' section.sql \
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,10 @@ ce poste et sur le runner de son écosystème, qui en reçoit une copie chiffré
|
||||||
d'une seconde couche ; le script refuse tout fichier dont l'en-tête n'est pas
|
d'une seconde couche ; le script refuse tout fichier dont l'en-tête n'est pas
|
||||||
`$ANSIBLE_VAULT`, garde le chemin de chaque voûte (elles s'appellent presque toutes
|
`$ANSIBLE_VAULT`, garde le chemin de chaque voûte (elles s'appellent presque toutes
|
||||||
`vault.yml`) et relit l'archive empreinte par empreinte. Restauration :
|
`vault.yml`) et relit l'archive empreinte par empreinte. Restauration :
|
||||||
`tar -xf setops-voutes-*.tar -C <dossier des dépôts>`. **À refaire après toute écriture
|
`tar -xf "$(ls -t setops-voutes-*.tar | head -1)" -C <dossier des dépôts>` (la plus récente).
|
||||||
|
Chaque export porte sa date (`setops-voutes-<date>-<poste>.tar`, l'heure en plus au second
|
||||||
|
export du jour) et n'écrit rien si les voûtes n'ont pas changé depuis la dernière archive.
|
||||||
|
**À refaire après toute écriture
|
||||||
dans une voûte**, comme les clés après toute voûte nouvelle.
|
dans une voûte**, comme les clés après toute voûte nouvelle.
|
||||||
|
|
||||||
> **Ce chiffre était trois, et il a baissé sans que rien ne le signale.** Un coffre répliqué deux fois reste
|
> **Ce chiffre était trois, et il a baissé sans que rien ne le signale.** Un coffre répliqué deux fois reste
|
||||||
|
|
@ -77,7 +80,7 @@ Une procédure rangée dans le dépôt serait donc inaccessible exactement quand
|
||||||
La clé se suffit donc à elle-même. À côté de l'archive :
|
La clé se suffit donc à elle-même. À côté de l'archive :
|
||||||
|
|
||||||
```
|
```
|
||||||
setops-cles-<poste>.tar.gpg les clés, chiffrées
|
setops-cles-<date>-<poste>.tar.gpg les clés, chiffrées (une archive datée par export)
|
||||||
restaurer_cles.py le script, autonome
|
restaurer_cles.py le script, autonome
|
||||||
LISEZ-MOI-RESTAURATION.txt le mode d'emploi, et les commandes manuelles
|
LISEZ-MOI-RESTAURATION.txt le mode d'emploi, et les commandes manuelles
|
||||||
```
|
```
|
||||||
|
|
@ -89,8 +92,9 @@ LISEZ-MOI-RESTAURATION.txt le mode d'emploi, et les commandes manuelles
|
||||||
Sur la machine neuve, il ne faut que `gpg`, `python3` et la phrase de passe :
|
Sur la machine neuve, il ne faut que `gpg`, `python3` et la phrase de passe :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 restaurer_cles.py setops-cles-*.tar.gpg --lister # voir sans rien écrire
|
A="$(ls -t setops-cles-*.tar* | head -1)" # la plus récente
|
||||||
python3 restaurer_cles.py setops-cles-*.tar.gpg # remettre en place
|
python3 restaurer_cles.py "$A" --lister # voir sans rien écrire
|
||||||
|
python3 restaurer_cles.py "$A" # remettre en place
|
||||||
```
|
```
|
||||||
|
|
||||||
Le script remet chaque fichier à sa place selon son nom, et **repose les droits à 0600**.
|
Le script remet chaque fichier à sa place selon son nom, et **repose les droits à 0600**.
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,8 @@ vault_postgresql_keycloak: ""
|
||||||
vault_bd_keycloak: ""
|
vault_bd_keycloak: ""
|
||||||
vault_bd_forgejo: ""
|
vault_bd_forgejo: ""
|
||||||
vault_bd_icingadb: ""
|
vault_bd_icingadb: ""
|
||||||
# La base des COMPTES de la console (mode `db`) — distincte de celle du moteur.
|
# La base de la console (vigie) dans tous les modes : comptes en mode `db`, préférences
|
||||||
|
# et migrations partout — distincte de celle du moteur.
|
||||||
vault_bd_icingaweb2: ""
|
vault_bd_icingaweb2: ""
|
||||||
|
|
||||||
# --- Forge (Forgejo) ---
|
# --- Forge (Forgejo) ---
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ depots:
|
||||||
composant: main
|
composant: main
|
||||||
# Sur CHAQUE machine, via `client_pki`. Le paquet le plus critique de la liste.
|
# Sur CHAQUE machine, via `client_pki`. Le paquet le plus critique de la liste.
|
||||||
paquets:
|
paquets:
|
||||||
step-cli: 0.30.6-1
|
step-cli: 0.31.0-1
|
||||||
|
|
||||||
icinga:
|
icinga:
|
||||||
uri: https://packages.icinga.com/debian
|
uri: https://packages.icinga.com/debian
|
||||||
|
|
@ -37,12 +37,12 @@ depots:
|
||||||
icinga-archive-keyring: 2.0.0-1+debian13
|
icinga-archive-keyring: 2.0.0-1+debian13
|
||||||
icingacli: 2.14.0-2+debian13
|
icingacli: 2.14.0-2+debian13
|
||||||
icingadb: 1.5.1-8+debian13
|
icingadb: 1.5.1-8+debian13
|
||||||
icingadb-redis: 8.2.9-1+debian13
|
icingadb-redis: 8.2.10-1+debian13
|
||||||
icingadb-web: 1.4.0-1+debian13
|
icingadb-web: 1.4.0-1+debian13
|
||||||
icinga-l10n: 1.4.0-1+debian13
|
icinga-l10n: 1.4.0-1+debian13
|
||||||
icinga-php-legacy: 1.1.0-1+debian13
|
icinga-php-legacy: 1.1.0-1+debian13
|
||||||
icinga-php-library: 1.0.0-2+debian13
|
icinga-php-library: 1.0.1-1+debian13
|
||||||
icinga-php-thirdparty: 1.0.0-2+debian13
|
icinga-php-thirdparty: 1.0.1-1+debian13
|
||||||
icingaweb2: 2.14.0-2+debian13
|
icingaweb2: 2.14.0-2+debian13
|
||||||
icingaweb2-common: 2.14.0-2+debian13
|
icingaweb2-common: 2.14.0-2+debian13
|
||||||
icingaweb2-module-monitoring: 2.12.6-1+debian13
|
icingaweb2-module-monitoring: 2.12.6-1+debian13
|
||||||
|
|
@ -53,7 +53,12 @@ depots:
|
||||||
suite: stable
|
suite: stable
|
||||||
composant: main
|
composant: main
|
||||||
# `alloy` est universel comme `step-cli` : `client_metrique` le pose partout.
|
# `alloy` est universel comme `step-cli` : `client_metrique` le pose partout.
|
||||||
|
# RELEVES LE 2026-10-03 sur ce que portent les locataires depuis leur reconstruction du
|
||||||
|
# 2026-10-01 : leurs runners n'ont pas ce cache et avaient tire la derniere version
|
||||||
|
# publiee. Le cache du poste, lui, servait encore les anciennes — un deploiement depuis
|
||||||
|
# le poste aurait tente de les RETROGRADER (`paquets_tiers` installe les .deb du cache).
|
||||||
|
# Le site, encore en 1.19.2 / 13.2.1 / 3.7.7, montera a son prochain passage.
|
||||||
paquets:
|
paquets:
|
||||||
alloy: 1.19.2-1
|
alloy: 1.20.1-1
|
||||||
grafana: 13.2.1
|
grafana: 13.2.3
|
||||||
loki: 3.7.7
|
loki: 3.7.8
|
||||||
|
|
|
||||||
121
playbooks/backup/restauration.yml
Normal file
121
playbooks/backup/restauration.yml
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
---
|
||||||
|
# L'ETAT D'UNE INCARNATION PRECEDENTE — voir roles/client_backup/templates/restaurer.sh.j2
|
||||||
|
#
|
||||||
|
# make restauration-etat [HOTE=...]
|
||||||
|
# ce qui attend dans le depot, et ce que chaque jeu est devenu (lecture seule) ;
|
||||||
|
# make sauvegarder-maintenant [HOTE=...]
|
||||||
|
# deposer TOUT DE SUITE, et attendre que ce soit fait. A faire juste avant `raser` :
|
||||||
|
# sinon la reconstruction remet l'etat de la derniere nuit, et perd la journee ;
|
||||||
|
# make temoins-etat [HOTE=...] [SOURCE=candidat]
|
||||||
|
# l'etat VIVANT est-il celui d'avant le rasage ? Compare a l'instantane etiquete
|
||||||
|
# « avant-raser » (lecture seule). Une perte, ou une identite changee (cles de l'AC,
|
||||||
|
# DKIM, instanceid, mot de passe d'une entree de l'annuaire), fait echouer le jeu ;
|
||||||
|
# make restauration-renoncer HOTE=<hote> JEU=<jeu> CONFIRMER=true
|
||||||
|
# ECARTER un etat anterieur sans le remettre. La sauvegarde du noeud, qui refusait
|
||||||
|
# de deposer pour ne pas le chasser de la retention, reprend — et l'etat d'avant
|
||||||
|
# finira par sortir de la retention. C'est une decision, pas une reparation.
|
||||||
|
#
|
||||||
|
# La REMISE, elle, n'a pas de cible : elle est faite par le role proprietaire de l'etat,
|
||||||
|
# au deploiement, au moment ou il le creerait neuf.
|
||||||
|
- name: L'etat d'une incarnation precedente
|
||||||
|
hosts: client_backup
|
||||||
|
become: true
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
restauration_action: etat
|
||||||
|
restauration_jeu: ""
|
||||||
|
restauration_source: avant-raser
|
||||||
|
tasks:
|
||||||
|
- name: Refuser un renoncement sans jeu nomme
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- restauration_jeu | length > 0
|
||||||
|
fail_msg: "JEU=<jeu> requis (voir `make restauration-etat` pour les noms)."
|
||||||
|
when: restauration_action == 'renoncer'
|
||||||
|
|
||||||
|
- name: Lire ce qui attend, et ce qui a ete tranche
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv: [/usr/local/sbin/setops-restaurer, etat]
|
||||||
|
register: restauration_lue
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
when: restauration_action == 'etat'
|
||||||
|
|
||||||
|
- name: Etat
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: "{{ (restauration_lue.stdout_lines | default([])) + (restauration_lue.stderr_lines | default([])) }}"
|
||||||
|
when: restauration_action == 'etat'
|
||||||
|
|
||||||
|
# L'unite est `oneshot` : `systemctl start` rend la main quand le depot est fait, et
|
||||||
|
# echoue s'il a echoue — y compris quand la garde refuse (etat d'avant non remis).
|
||||||
|
- name: Ce noeud a-t-il quelque chose a deposer ?
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: /etc/systemd/system/setops-sauvegarde.service
|
||||||
|
register: restauration_unite
|
||||||
|
when: restauration_action == 'sauvegarder'
|
||||||
|
|
||||||
|
# LE DEPOT D'AVANT RASAGE EST ETIQUETE (2026-10-07), et la retention le garde jusqu'a la
|
||||||
|
# reconstruction suivante. Un noeud qui n'a pas encore cette unite deposerait SANS
|
||||||
|
# etiquette, et la premiere sauvegarde d'apres reconstruction le chasserait : c'est
|
||||||
|
# exactement ce qui s'est passe a M4. On refuse plutot que de deposer a moitie.
|
||||||
|
- name: L'unite du depot d'avant rasage est-elle deployee ?
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: /etc/systemd/system/setops-sauvegarde-avant-raser.service
|
||||||
|
register: restauration_unite_avant_raser
|
||||||
|
when:
|
||||||
|
- restauration_action == 'sauvegarder'
|
||||||
|
- restauration_unite.stat.exists
|
||||||
|
|
||||||
|
- name: Refuser un depot que la retention ne garderait pas
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- restauration_unite_avant_raser.stat.exists
|
||||||
|
fail_msg: >-
|
||||||
|
setops-sauvegarde-avant-raser.service absente : redeployer client_backup
|
||||||
|
(make appliquer GROUPE=client_backup) avant de raser.
|
||||||
|
when:
|
||||||
|
- restauration_action == 'sauvegarder'
|
||||||
|
- restauration_unite.stat.exists
|
||||||
|
|
||||||
|
- name: Deposer maintenant, etiquete avant rasage
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: setops-sauvegarde-avant-raser.service
|
||||||
|
state: started
|
||||||
|
when:
|
||||||
|
- restauration_action == 'sauvegarder'
|
||||||
|
- restauration_unite.stat.exists
|
||||||
|
|
||||||
|
# Code 4 = rien a comparer (aucun etat, ou aucun instantane d'avant rasage) : ce n'est
|
||||||
|
# pas un echec. Code 1 = ecart : on affiche d'abord, on echoue ensuite.
|
||||||
|
- name: Temoins — l'etat vivant contre l'instantane d'avant rasage
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv: >-
|
||||||
|
{{ ['/usr/local/sbin/setops-restaurer', 'temoins']
|
||||||
|
+ (['--candidat'] if restauration_source == 'candidat' else []) }}
|
||||||
|
register: restauration_temoins
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
when: restauration_action == 'temoins'
|
||||||
|
|
||||||
|
- name: Temoins
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: "{{ (restauration_temoins.stdout_lines | default([])) + (restauration_temoins.stderr_lines | default([])) }}"
|
||||||
|
when: restauration_action == 'temoins'
|
||||||
|
|
||||||
|
- name: Temoins — verdict
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- restauration_temoins.rc in [0, 4]
|
||||||
|
fail_msg: >-
|
||||||
|
{{ 'ECART : une perte, ou une identite changee (voir ci-dessus).'
|
||||||
|
if restauration_temoins.rc == 1 else
|
||||||
|
'setops-restaurer temoins a echoue (code ' ~ restauration_temoins.rc ~ ') : '
|
||||||
|
~ 'client_backup est-il deploye ?' }}
|
||||||
|
quiet: true
|
||||||
|
when: restauration_action == 'temoins'
|
||||||
|
|
||||||
|
- name: Ecarter l'etat anterieur de ce jeu
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv: [/usr/local/sbin/setops-restaurer, acter, "{{ restauration_jeu }}", abandonne]
|
||||||
|
changed_when: true
|
||||||
|
when: restauration_action == 'renoncer'
|
||||||
|
|
@ -37,3 +37,20 @@
|
||||||
pre_tasks: *pre_sante
|
pre_tasks: *pre_sante
|
||||||
roles:
|
roles:
|
||||||
- client_sante
|
- client_sante
|
||||||
|
|
||||||
|
# LE RETRAIT, SUR LES HOTES SORTIS DU GROUPE (2026-10-03). Un role qu'on cesse d'appliquer
|
||||||
|
# laisse son minuteur derriere lui : les hyperviseurs du site ont pousse vers une adresse
|
||||||
|
# perimee pendant des semaines (voir `roles/client_sante/tasks/retirer.yml`).
|
||||||
|
#
|
||||||
|
# LE GROUPE EST NOMME, pas deduit (`all:!client_sante`) : un hyperviseur n'est pas une VM
|
||||||
|
# de la flotte, et un play qui le touche doit le dire. Chez un tenant, `hyperviseurs`
|
||||||
|
# n'existe pas et ce play ne trouve personne.
|
||||||
|
- name: Intégration client_sante — retrait sur les hyperviseurs qui n'y sont plus
|
||||||
|
hosts: hyperviseurs:!client_sante
|
||||||
|
become: true
|
||||||
|
gather_facts: false
|
||||||
|
tasks:
|
||||||
|
- name: Retirer le porteur de santé
|
||||||
|
ansible.builtin.include_role:
|
||||||
|
name: client_sante
|
||||||
|
tasks_from: retirer.yml
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,7 @@
|
||||||
loop: "{{ genome_a_pousser }}"
|
loop: "{{ genome_a_pousser }}"
|
||||||
loop_control:
|
loop_control:
|
||||||
label: "{{ item.dest }}"
|
label: "{{ item.dest }}"
|
||||||
|
register: genome_emballe
|
||||||
changed_when: true
|
changed_when: true
|
||||||
|
|
||||||
- name: Porter les colis jusqu'au runner
|
- name: Porter les colis jusqu'au runner
|
||||||
|
|
@ -255,6 +256,45 @@
|
||||||
label: "{{ item.depot }}"
|
label: "{{ item.depot }}"
|
||||||
index_var: idx
|
index_var: idx
|
||||||
|
|
||||||
|
# LES RELEASES AUSSI (2026-10-07). Le colis ne portait que la branche : `v2026.08.21` et
|
||||||
|
# `v2026.10.07` manquaient a la forge du site. Il porte desormais les etiquettes ; on
|
||||||
|
# demande a la forge si elle a TOUTES celles du poste. 404 = aucune etiquette sur ce
|
||||||
|
# depot, ce qui est juste quand le poste n'en a pas non plus.
|
||||||
|
- name: Demander à la forge les étiquettes qu'elle porte
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: >-
|
||||||
|
https://forge.{{ domaine_interne }}/api/v1/repos/{{ serveur_ops_forge_organisation
|
||||||
|
| default('genome') }}/{{ item.depot }}/git/refs/tags
|
||||||
|
validate_certs: false
|
||||||
|
status_code: [200, 404]
|
||||||
|
loop: "{{ genome_a_pousser }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.depot }}"
|
||||||
|
register: genome_forge_etiquettes
|
||||||
|
retries: 4
|
||||||
|
delay: 3
|
||||||
|
until: genome_forge_etiquettes is succeeded
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: La forge porte-t-elle toutes les releases du poste ?
|
||||||
|
vars:
|
||||||
|
genome_etiquettes_poste: "{{ (genome_emballe.results[idx].stdout | from_json).etiquettes | default([]) }}"
|
||||||
|
genome_etiquettes_forge: >-
|
||||||
|
{{ (genome_forge_etiquettes.results[idx].json | default([]) if genome_forge_etiquettes.results[idx].status == 200 else [])
|
||||||
|
| map(attribute='ref') | map('regex_replace', '^refs/tags/', '') | list }}
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- genome_etiquettes_poste | difference(genome_etiquettes_forge) | length == 0
|
||||||
|
fail_msg: >-
|
||||||
|
{{ item.depot }} : la forge n'a pas
|
||||||
|
{{ genome_etiquettes_poste | difference(genome_etiquettes_forge) | join(', ') }}.
|
||||||
|
success_msg: >-
|
||||||
|
{{ item.depot }} : {{ genome_etiquettes_poste | length }} etiquette(s) du poste, toutes sur la forge.
|
||||||
|
loop: "{{ genome_a_pousser }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.depot }}"
|
||||||
|
index_var: idx
|
||||||
|
|
||||||
# LE RUNNER DU SITE AVANCE SON PROPRE CLONE, ET SA CONSOLE NE LE SAIT PAS (2026-09-20).
|
# LE RUNNER DU SITE AVANCE SON PROPRE CLONE, ET SA CONSOLE NE LE SAIT PAS (2026-09-20).
|
||||||
#
|
#
|
||||||
# C'est LUI qui pousse, donc il doit d'abord recevoir : ce playbook fait avancer le
|
# C'est LUI qui pousse, donc il doit d'abord recevoir : ce playbook fait avancer le
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,15 @@
|
||||||
name: auditd
|
name: auditd
|
||||||
state: restarted
|
state: restarted
|
||||||
failed_when: false
|
failed_when: false
|
||||||
|
|
||||||
|
# REDEMARRER `auditd` NE RECHARGE PAS LES REGLES (mesure du 2026-10-03, Debian 13).
|
||||||
|
#
|
||||||
|
# Elles sont chargees par `audit-rules.service` (`augenrules --load`), une unite a part
|
||||||
|
# dont `auditd` depend — et qu'un redemarrage d'`auditd` ne rejoue pas. Le handler
|
||||||
|
# ci-dessus laissait donc le fichier juste et le noyau sur les regles du dernier demarrage.
|
||||||
|
#
|
||||||
|
# SANS `failed_when: false` : une regle que le noyau refuse doit se voir ici, pas au
|
||||||
|
# prochain redemarrage de la machine.
|
||||||
|
- name: Recharger les regles auditd
|
||||||
|
ansible.builtin.command: augenrules --load
|
||||||
|
changed_when: true
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,9 @@
|
||||||
path: "/etc/audit/rules.d/{{ item }}"
|
path: "/etc/audit/rules.d/{{ item }}"
|
||||||
state: absent
|
state: absent
|
||||||
loop: "{{ auditd_fichiers_perimes }}"
|
loop: "{{ auditd_fichiers_perimes }}"
|
||||||
notify: Restart auditd
|
notify:
|
||||||
|
- Restart auditd
|
||||||
|
- Recharger les regles auditd
|
||||||
when: auditd_enabled | bool
|
when: auditd_enabled | bool
|
||||||
|
|
||||||
# LA RETENTION EST UN CHOIX, DONC ELLE S'ECRIT (2026-09-11). Voir defaults/main.yml pour
|
# LA RETENTION EST UN CHOIX, DONC ELLE S'ECRIT (2026-09-11). Voir defaults/main.yml pour
|
||||||
|
|
@ -48,7 +50,9 @@
|
||||||
owner: root
|
owner: root
|
||||||
group: root
|
group: root
|
||||||
mode: "0640"
|
mode: "0640"
|
||||||
notify: Restart auditd
|
notify:
|
||||||
|
- Restart auditd
|
||||||
|
- Recharger les regles auditd
|
||||||
when: auditd_enabled | bool
|
when: auditd_enabled | bool
|
||||||
|
|
||||||
# `failed_when: false` A CACHE LA PANNE PENDANT TOUT UN DEPLOIEMENT (2026-08-30).
|
# `failed_when: false` A CACHE LA PANNE PENDANT TOUT UN DEPLOIEMENT (2026-08-30).
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,20 @@
|
||||||
-w /etc/step-ca/ -p wa -k pki-autorite
|
-w /etc/step-ca/ -p wa -k pki-autorite
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
# L'EXPORTATEUR DE METRIQUES LIT L'HORLOGE, IL NE LA REGLE PAS (2026-10-03).
|
||||||
|
#
|
||||||
|
# `adjtimex` sert aux deux : regler ET lire. Le collecteur `timex` de `node_exporter` le lit
|
||||||
|
# a chaque collecte, et la regle `time-change` ci-dessous ne peut pas distinguer — le mode
|
||||||
|
# est dans une structure pointee, hors de portee d'un filtre `-F`. Mesure au site : 4
|
||||||
|
# evenements par minute, 35 % de toute la piste d'audit d'une VM, pour un appel en lecture.
|
||||||
|
#
|
||||||
|
# UNE REGLE `never` ETROITE, ET DEVANT : l'audit retient la PREMIERE regle qui correspond.
|
||||||
|
# Elle ne vise que `adjtimex`, que ce binaire-la : `settimeofday`/`clock_settime` restent
|
||||||
|
# audites pour lui aussi, et chrony — qui regle vraiment — reste entierement audite.
|
||||||
|
# Eprouve avant d'ecrire : 4 evenements/min sans elle, 0 avec ; et `auditctl` accepte un
|
||||||
|
# `exe=` absent, donc la ligne ne casse pas le chargement d'une machine sans exportateur
|
||||||
|
# (ce que ferait un `-w` vers un chemin absent, voir plus haut).
|
||||||
|
-a never,exit -F arch=b64 -S adjtimex -F exe=/usr/bin/prometheus-node-exporter
|
||||||
-a always,exit -F arch=b64 -S adjtimex,settimeofday,clock_settime -k time-change
|
-a always,exit -F arch=b64 -S adjtimex,settimeofday,clock_settime -k time-change
|
||||||
-a always,exit -F arch=b64 -S sethostname,setdomainname -k system-locale
|
-a always,exit -F arch=b64 -S sethostname,setdomainname -k system-locale
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ Sans eux, le rôle refuse de s'exécuter (assertion).
|
||||||
| `client_backup_repo` | `sftp:restic@<cible>:<hôte>` | Un sous-dossier par nœud |
|
| `client_backup_repo` | `sftp:restic@<cible>:<hôte>` | Un sous-dossier par nœud |
|
||||||
| `client_backup_staging` | `/var/backups/setops` | Préparation des dumps |
|
| `client_backup_staging` | `/var/backups/setops` | Préparation des dumps |
|
||||||
| `client_backup_retention` | `--keep-daily 7 --keep-weekly 4 --keep-monthly 6` | `restic forget` |
|
| `client_backup_retention` | `--keep-daily 7 --keep-weekly 4 --keep-monthly 6` | `restic forget` |
|
||||||
|
| `client_backup_etiquette_avant_raser` | `avant-raser` | étiquette du dépôt d'avant rasage, gardée par la rétention jusqu'à la reconstruction suivante |
|
||||||
| `client_backup_horaire` | `*-*-* 02:30:00` | Timer systemd |
|
| `client_backup_horaire` | `*-*-* 02:30:00` | Timer systemd |
|
||||||
| `client_backup_jobs` | `[]` | **À déclarer par nœud** |
|
| `client_backup_jobs` | `[]` | **À déclarer par nœud** |
|
||||||
|
|
||||||
|
|
@ -55,7 +56,11 @@ Sans eux, le rôle refuse de s'exécuter (assertion).
|
||||||
vérifier que chaque nœud porteur d'état déclare ses jobs.
|
vérifier que chaque nœud porteur d'état déclare ses jobs.
|
||||||
- Un dépôt neuf est **vide jusqu'à la première exécution** : après une reconstruction
|
- Un dépôt neuf est **vide jusqu'à la première exécution** : après une reconstruction
|
||||||
from-zero, déclencher une première sauvegarde plutôt que d'attendre 02:30.
|
from-zero, déclencher une première sauvegarde plutôt que d'attendre 02:30.
|
||||||
- Restauration : `restic restore` avec le même mot de passe (cf. `docs/runbooks-exploitation.md`).
|
- Restauration : **faite par la reconstruction elle-même** depuis le 2026-09-30 — chaque rôle
|
||||||
|
propriétaire inclut `tasks/restaurer.yml` et remet l'état de l'incarnation précédente ;
|
||||||
|
l'outil de nœud est `setops-restaurer` (cf. `docs/runbooks-exploitation.md` §5.0).
|
||||||
|
- Témoins : `setops-restaurer temoins` (comparateur `setops-temoins`) confronte l'état vivant
|
||||||
|
à l'instantané d'avant rasage ; `make temoins-etat`, et l'étape `bilan` de la reconstruction.
|
||||||
|
|
||||||
## Prérequis
|
## Prérequis
|
||||||
- `serveur_backup` déployé, et sa `serveur_backup_pubkey` correspondant à la clé privée de
|
- `serveur_backup` déployé, et sa `serveur_backup_pubkey` correspondant à la clé privée de
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
# Chaque nœud déclare ses "jobs" ; restic chiffre côté client + applique la rétention.
|
# Chaque nœud déclare ses "jobs" ; restic chiffre côté client + applique la rétention.
|
||||||
client_backup_paquets:
|
client_backup_paquets:
|
||||||
- restic
|
- restic
|
||||||
|
# `setops-restaurer` remet les repertoires par `rsync --delete` (absent du gabarit).
|
||||||
|
- rsync
|
||||||
|
|
||||||
# Cible (nœud serveur_backup) + transport SSH.
|
# Cible (nœud serveur_backup) + transport SSH.
|
||||||
client_backup_cible: "backup-01.{{ domaine_interne }}"
|
client_backup_cible: "backup-01.{{ domaine_interne }}"
|
||||||
|
|
@ -18,6 +20,10 @@ client_backup_staging: "/var/backups/setops"
|
||||||
|
|
||||||
# Rétention (restic forget).
|
# Rétention (restic forget).
|
||||||
client_backup_retention: "--keep-daily 7 --keep-weekly 4 --keep-monthly 6"
|
client_backup_retention: "--keep-daily 7 --keep-weekly 4 --keep-monthly 6"
|
||||||
|
# L'instantane d'avant rasage (`make sauvegarder-maintenant`) porte cette etiquette, et la
|
||||||
|
# retention la garde EN PLUS de la regle ci-dessus : jusqu'a la reconstruction suivante
|
||||||
|
# (decision de l'exploitant, 2026-10-07). Voir `templates/sauvegarder.sh.j2`.
|
||||||
|
client_backup_etiquette_avant_raser: "avant-raser"
|
||||||
|
|
||||||
# Planification (timer systemd).
|
# Planification (timer systemd).
|
||||||
client_backup_horaire: "*-*-* 02:30:00"
|
client_backup_horaire: "*-*-* 02:30:00"
|
||||||
|
|
@ -74,9 +80,42 @@ client_backup_catalogue:
|
||||||
# qu'il a appris. Les regles, elles, se redeploient et ne sont pas ici.
|
# qu'il a appris. Les regles, elles, se redeploient et ne sont pas ici.
|
||||||
nom: rspamd
|
nom: rspamd
|
||||||
chemins: ["/var/lib/rspamd"]
|
chemins: ["/var/lib/rspamd"]
|
||||||
serveur_web_frontal:
|
# PAS DE `serveur_web_frontal` (retire le 2026-09-30) : le frontal RELAIE, il ne sert
|
||||||
nom: web_frontal
|
# rien lui-meme — ses vhosts, son WAF et sa page 404 se redeploient depuis le depot.
|
||||||
chemins: ["{{ serveur_web_frontal_racine_base | default('/srv/web') }}"]
|
# `/srv/web` venait de l'epoque ou il servait du statique ; ses instantanes etaient vides.
|
||||||
|
# LES METRIQUES ET LES JOURNAUX (2026-10-07). Ils repartaient de zero a chaque
|
||||||
|
# reconstruction. Ils s'ecrivent en continu : une copie A FROID (service arrete le temps
|
||||||
|
# de copier, `setops-copie-a-froid`) les pose dans le repertoire de preparation, et c'est
|
||||||
|
# cette copie que restic emporte. `vivant` dit ou vit la base, pour la restauration et les
|
||||||
|
# temoins, qui la comparent a l'instantane.
|
||||||
|
serveur_prometheus:
|
||||||
|
nom: prometheus
|
||||||
|
vivant: "{{ serveur_prometheus_donnees | default('/var/lib/prometheus/metrics2') }}"
|
||||||
|
commande: >-
|
||||||
|
/usr/local/sbin/setops-copie-a-froid
|
||||||
|
{{ serveur_prometheus_service | default('prometheus') }}
|
||||||
|
{{ serveur_prometheus_donnees | default('/var/lib/prometheus/metrics2') }}
|
||||||
|
{{ client_backup_staging }}/prometheus/metrics2 --blocs-immuables
|
||||||
|
chemins: ["{{ client_backup_staging }}/prometheus"]
|
||||||
|
serveur_loki:
|
||||||
|
nom: loki
|
||||||
|
vivant: "{{ serveur_loki_chemin | default('/var/lib/loki') }}"
|
||||||
|
commande: >-
|
||||||
|
/usr/local/sbin/setops-copie-a-froid
|
||||||
|
{{ serveur_loki_service | default('loki') }}
|
||||||
|
{{ serveur_loki_chemin | default('/var/lib/loki') }}
|
||||||
|
{{ client_backup_staging }}/loki/donnees
|
||||||
|
chemins: ["{{ client_backup_staging }}/loki"]
|
||||||
|
serveur_icinga:
|
||||||
|
# L'AC D'ICINGA FAIT L'IDENTITE DE SA BASE (2026-10-07). Icinga 2 derive l'environnement
|
||||||
|
# d'Icinga DB de la cle publique de son AC, et chaque identifiant d'hote, de service et
|
||||||
|
# d'historique de cet environnement. Recreee a chaque reconstruction, elle rendait
|
||||||
|
# l'historique restaure orphelin : on l'avait donc jete (decision du 2026-09-30, revue
|
||||||
|
# le 2026-10-07 — l'historique, c'est la disponibilite qu'on doit pouvoir prouver).
|
||||||
|
nom: icinga
|
||||||
|
chemins:
|
||||||
|
- "{{ (serveur_icinga_ca | default('/var/lib/icinga2/ca/ca.crt')) | dirname }}"
|
||||||
|
- /var/lib/icinga2/icingadb.env
|
||||||
serveur_web_dorsal:
|
serveur_web_dorsal:
|
||||||
nom: web_dorsal
|
nom: web_dorsal
|
||||||
chemins: ["{{ serveur_web_dorsal_racine | default('/srv/webapp') }}"]
|
chemins: ["{{ serveur_web_dorsal_racine | default('/srv/webapp') }}"]
|
||||||
|
|
@ -111,6 +150,8 @@ client_backup_icinga_hote: "{{ (groups['serveur_icinga'] | default([]) | first)
|
||||||
client_backup_icinga_utilisateur: "setops-depot"
|
client_backup_icinga_utilisateur: "setops-depot"
|
||||||
client_backup_icinga_motdepasse: "{{ vault_icinga_api_depot | default('') }}"
|
client_backup_icinga_motdepasse: "{{ vault_icinga_api_depot | default('') }}"
|
||||||
client_backup_ca_verification: "/etc/setops/icinga-ca.crt"
|
client_backup_ca_verification: "/etc/setops/icinga-ca.crt"
|
||||||
|
# L'Icinga auquel ce noeud a deja rapporte (empreinte de son AC) — voir tasks/verifier.yml.
|
||||||
|
client_backup_marque_icinga: "/etc/setops/client_backup-icinga-entendu"
|
||||||
client_backup_icinga_ca_source: "/var/lib/icinga2/ca/ca.crt"
|
client_backup_icinga_ca_source: "/var/lib/icinga2/ca/ca.crt"
|
||||||
|
|
||||||
# `ttl` du resultat passif : au-dela, Icinga perime le service de lui-meme — c'est ce qui
|
# `ttl` du resultat passif : au-dela, Icinga perime le service de lui-meme — c'est ce qui
|
||||||
|
|
@ -128,3 +169,27 @@ client_backup_restauration_part: "10%"
|
||||||
# Huit jours : une semaine entre deux controles, plus une journee de marge. Au-dela sans
|
# Huit jours : une semaine entre deux controles, plus une journee de marge. Au-dela sans
|
||||||
# nouvelle, Icinga passe le service au rouge (voir `serveur_icinga_fraicheur_restauration`).
|
# nouvelle, Icinga passe le service au rouge (voir `serveur_icinga_fraicheur_restauration`).
|
||||||
client_backup_ttl_restauration: 691200
|
client_backup_ttl_restauration: 691200
|
||||||
|
|
||||||
|
# Combien de temps un script attend un depot verrouille par un autre (`restic --retry-lock`)
|
||||||
|
# avant d'echouer. Le plus long des occupants est le controle de restauration ; il relit
|
||||||
|
# 10 % du depot et le restaure entier.
|
||||||
|
client_backup_attente_verrou: "30m"
|
||||||
|
|
||||||
|
# --- LA RESTAURATION DANS LA RECONSTRUCTION (2026-09-30) ----------------------------
|
||||||
|
#
|
||||||
|
# Jusqu'ici une reconstruction repartait d'un etat NEUF : les instantanes se restauraient
|
||||||
|
# pour PROUVER qu'ils s'ouvrent, jamais pour etre remis en service. Desormais chaque role
|
||||||
|
# proprietaire d'un etat inclut `tasks/restaurer.yml` au moment ou il le creerait neuf, et
|
||||||
|
# remet celui de l'incarnation precedente s'il existe. Voir `templates/restaurer.sh.j2`.
|
||||||
|
#
|
||||||
|
# `false` : ne jamais restaurer (chaque jeu est alors acte « desactive », et la
|
||||||
|
# sauvegarde ne s'en trouve pas bloquee).
|
||||||
|
client_backup_restauration_active: true
|
||||||
|
# Imposer un instantane (son `short_id`) au lieu du dernier anterieur a la naissance.
|
||||||
|
# Par hote, et le temps d'une passe : `-e client_backup_restauration_instantane=abcd1234`.
|
||||||
|
client_backup_restauration_instantane: ""
|
||||||
|
# Ou chaque jeu laisse la trace de ce qui lui est arrive (restaure, neuf, en_place...).
|
||||||
|
client_backup_restauration_marques: "/etc/setops/restauration"
|
||||||
|
# Ce qui etait en place avant d'etre ecrase. Hors des chemins sauvegardes : on ne veut
|
||||||
|
# pas deposer chez l'hebergeur l'etat qu'on vient justement de remplacer.
|
||||||
|
client_backup_restauration_mise_de_cote: "/var/backups/setops-avant-restauration"
|
||||||
|
|
|
||||||
62
roles/client_backup/files/setops-copie-a-froid.sh
Normal file
62
roles/client_backup/files/setops-copie-a-froid.sh
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Géré par Set-OPS (rôle client_backup). Ne pas éditer à la main.
|
||||||
|
#
|
||||||
|
# setops-copie-a-froid SERVICE SOURCE DESTINATION [--blocs-immuables]
|
||||||
|
#
|
||||||
|
# Copie COHERENTE d'une base qui ecrit en continu (Prometheus, Loki) : le service est
|
||||||
|
# arrete le temps de la copie, puis redemarre — QUOI QU'IL ARRIVE a la copie, et seulement
|
||||||
|
# s'il tournait avant. La destination n'est remplacee qu'une fois la copie complete.
|
||||||
|
#
|
||||||
|
# POURQUOI A FROID (decision de l'exploitant, 2026-10-07 : « on ne prend pas de risque »).
|
||||||
|
# Copier a chaud un WAL qu'on ecrit, c'est sauvegarder un fichier a moitie ecrit. L'autre
|
||||||
|
# voie — ne garder que les blocs fermes — perdait jusqu'aux deux dernieres heures de
|
||||||
|
# metriques a chaque reconstruction. Mesure au site : 2,9 Go de Prometheus se lisent en
|
||||||
|
# 4,2 s, son WAL seul en 0,3 s ; Loki entier en 2 s.
|
||||||
|
#
|
||||||
|
# `--blocs-immuables` : les sous-dossiers nommes par un ULID (26 caracteres, `01…`) sont
|
||||||
|
# les blocs de Prometheus, que rien ne reecrit apres leur fermeture. On les recopie par
|
||||||
|
# LIENS DURS — instantane, sans place — et le reste (WAL, `chunks_head`) par une vraie
|
||||||
|
# copie. Si la destination est sur un autre systeme de fichiers, on copie tout.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
service="${1:?service}"
|
||||||
|
source="${2:?source}"
|
||||||
|
dest="${3:?destination}"
|
||||||
|
blocs="${4:-}"
|
||||||
|
|
||||||
|
[[ -d "${source}" ]] || { echo "copie a froid : ${source} n'existe pas — rien a copier." >&2; exit 0; }
|
||||||
|
|
||||||
|
tmp="${dest}.en-cours"
|
||||||
|
rm -rf "${tmp}"
|
||||||
|
mkdir -p "$(dirname "${dest}")"
|
||||||
|
|
||||||
|
actif=0
|
||||||
|
if systemctl is-active --quiet "${service}"; then actif=1; fi
|
||||||
|
relancer() { if (( actif )); then systemctl start "${service}"; fi; }
|
||||||
|
|
||||||
|
debut=$(date +%s%N)
|
||||||
|
if (( actif )); then systemctl stop "${service}"; fi
|
||||||
|
trap relancer EXIT
|
||||||
|
|
||||||
|
if [[ "${blocs}" == "--blocs-immuables" ]]; then
|
||||||
|
mkdir -p "${tmp}"
|
||||||
|
for e in "${source}"/* "${source}"/.[!.]*; do
|
||||||
|
[[ -e "${e}" ]] || continue
|
||||||
|
n="$(basename "${e}")"
|
||||||
|
if [[ -d "${e}" && ${#n} -eq 26 && "${n}" == 01* ]]; then
|
||||||
|
cp -al "${e}" "${tmp}/${n}" 2>/dev/null || cp -a "${e}" "${tmp}/${n}"
|
||||||
|
else
|
||||||
|
cp -a "${e}" "${tmp}/${n}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
else
|
||||||
|
cp -a "${source}" "${tmp}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
trap - EXIT
|
||||||
|
relancer
|
||||||
|
fin=$(date +%s%N)
|
||||||
|
|
||||||
|
rm -rf "${dest}"
|
||||||
|
mv "${tmp}" "${dest}"
|
||||||
|
echo "copie a froid de ${source} : ${service} $( (( actif )) && echo "arrete (jusqu a la relance, rejeu du WAL non compris) $(( (fin - debut) / 1000000 )) ms" || echo "n'etait pas actif")"
|
||||||
427
roles/client_backup/files/setops-temoins.py
Normal file
427
roles/client_backup/files/setops-temoins.py
Normal file
|
|
@ -0,0 +1,427 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# Géré par Set-OPS (rôle client_backup). Ne pas éditer à la main.
|
||||||
|
"""Témoins d'état : l'état VIVANT de ce noeud est-il celui qu'il avait avant le rasage ?
|
||||||
|
|
||||||
|
POURQUOI (2026-10-07). Le bilan d'une reconstruction dit ce que chaque rôle a FAIT
|
||||||
|
(« restauré depuis tel instantané ») : c'est le système qui juge son propre travail. Le
|
||||||
|
2026-09-30, les empreintes avaient été relevées à la main, avant et après ; rien ne le
|
||||||
|
gardait dans le dépôt. Le 2026-10-07 (M4), l'instantané d'avant le rasage a même disparu
|
||||||
|
du dépôt dans l'heure, chassé par la rétention : il ne restait rien à quoi comparer.
|
||||||
|
|
||||||
|
Ce script compare l'instantané extrait par `setops-restaurer temoins` (sous `--avant`) à ce
|
||||||
|
qui vit sur le noeud. Il ne lit que ; il n'écrit rien.
|
||||||
|
|
||||||
|
VERDICTS, PAR JEU :
|
||||||
|
IDENTIQUE rien ne diffère ;
|
||||||
|
CONFORME rien de perdu, l'identité est intacte ; des changements ordinaires, listés
|
||||||
|
(un courriel reçu depuis, une table de sessions, le bayes de rspamd) ;
|
||||||
|
ECART une PERTE (fichier, entrée, base, table), ou un objet d'IDENTITÉ changé :
|
||||||
|
clés et certificats de l'AC, AC d'Icinga et son environnement, clé DKIM,
|
||||||
|
`instanceid` de Nextcloud, mot de passe d'une entrée de l'annuaire ; une
|
||||||
|
base dont plus aucune ligne d'avant ne subsiste ; une ligne d'historique
|
||||||
|
perdue ; des métriques qui ne couvrent plus l'avant-rasage ; des journaux
|
||||||
|
dont aucun morceau d'avant ne subsiste. Exactement ce que la reconstruction du
|
||||||
|
2026-09-30 avait perdu sans que rien ne le dise.
|
||||||
|
|
||||||
|
Code de sortie : 0 sans écart, 1 avec au moins un écart, 2 erreur d'usage.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import collections
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
LISTE_MAX = 8 # détails affichés par catégorie ; le compte, lui, est toujours entier
|
||||||
|
|
||||||
|
# Attributs que l'annuaire tient lui-même : ils changent à chaque écriture, ou à chaque
|
||||||
|
# rejeu (`slapadd` les repose), sans que la donnée ait bougé.
|
||||||
|
OPERATIONNELS = {"entrycsn", "modifytimestamp", "modifiersname", "createtimestamp",
|
||||||
|
"creatorsname", "contextcsn"}
|
||||||
|
|
||||||
|
|
||||||
|
class Rapport:
|
||||||
|
def __init__(self, jeu: str) -> None:
|
||||||
|
self.jeu = jeu
|
||||||
|
self.ecarts: list[str] = [] # pertes et identités changées
|
||||||
|
self.changes: list[str] = [] # changements ordinaires
|
||||||
|
self.retires: list[str] = [] # disparitions ordinaires (un cache, une base compactee)
|
||||||
|
self.nouveaux: list[str] = []
|
||||||
|
self.compte = 0 # objets comparés
|
||||||
|
|
||||||
|
def verdict(self) -> str:
|
||||||
|
if self.ecarts:
|
||||||
|
return "ECART"
|
||||||
|
return "CONFORME" if (self.changes or self.retires or self.nouveaux) else "IDENTIQUE"
|
||||||
|
|
||||||
|
def lignes(self, unite: str) -> list[str]:
|
||||||
|
out = [f" {self.jeu:<12} {self.verdict():<10} {self.compte} {unite}"]
|
||||||
|
for titre, liste in (("ECART", self.ecarts), ("modifie", self.changes),
|
||||||
|
("retire", self.retires), ("nouveau", self.nouveaux)):
|
||||||
|
for x in liste[:LISTE_MAX]:
|
||||||
|
out.append(f" {titre:<8} {x}")
|
||||||
|
if len(liste) > LISTE_MAX:
|
||||||
|
out.append(f" {titre:<8} ... et {len(liste) - LISTE_MAX} autre(s)")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def empreinte(p: Path) -> str:
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with p.open("rb") as f:
|
||||||
|
for bloc in iter(lambda: f.read(1 << 20), b""):
|
||||||
|
h.update(bloc)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def arbre(racine: Path, normaliser=None) -> dict[str, str]:
|
||||||
|
"""{chemin relatif (normalisé) : sha256} des fichiers réguliers sous `racine`."""
|
||||||
|
if racine.is_file():
|
||||||
|
return {racine.name: empreinte(racine)}
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
if not racine.is_dir():
|
||||||
|
return out
|
||||||
|
for p in sorted(racine.rglob("*")):
|
||||||
|
if p.is_file() and not p.is_symlink():
|
||||||
|
rel = p.relative_to(racine).as_posix()
|
||||||
|
out[normaliser(rel) if normaliser else rel] = empreinte(p)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def comparer_arbres(r: Rapport, avant: dict[str, str], vivant: dict[str, str], *,
|
||||||
|
strict, perte_stricte) -> None:
|
||||||
|
"""`strict(rel)` : un changement est un écart ; `perte_stricte(rel)` : une perte aussi."""
|
||||||
|
r.compte += len(avant)
|
||||||
|
for rel, h in sorted(avant.items()):
|
||||||
|
if rel not in vivant:
|
||||||
|
if perte_stricte(rel):
|
||||||
|
r.ecarts.append(f"perdu : {rel}")
|
||||||
|
else:
|
||||||
|
r.retires.append(rel)
|
||||||
|
elif vivant[rel] != h:
|
||||||
|
(r.ecarts if strict(rel) else r.changes).append(f"{'IDENTITE ' if strict(rel) else ''}{rel}")
|
||||||
|
r.nouveaux.extend(sorted(set(vivant) - set(avant)))
|
||||||
|
|
||||||
|
|
||||||
|
def jamais(_rel: str) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def toujours(_rel: str) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# --- Les jeux de fichiers ------------------------------------------------------------
|
||||||
|
|
||||||
|
def maildir(rel: str) -> str:
|
||||||
|
"""Un message garde son nom de base : `new/` -> `cur/` à la lecture, `:2,S` aux drapeaux."""
|
||||||
|
rel = re.sub(r"(^|/)(new|cur)/", r"\1msg/", rel)
|
||||||
|
return re.sub(r":2,[A-Za-z]*$", "", rel)
|
||||||
|
|
||||||
|
|
||||||
|
def est_message(rel: str) -> bool:
|
||||||
|
return "/msg/" in f"/{rel}"
|
||||||
|
|
||||||
|
|
||||||
|
def jeu_fichiers(jeu: str, chemins: list[str], avant: Path) -> Rapport:
|
||||||
|
r = Rapport(jeu)
|
||||||
|
for c in chemins:
|
||||||
|
a_avant, a_vivant = avant / c.lstrip("/"), Path(c)
|
||||||
|
if jeu == "step_ca":
|
||||||
|
# Les clés et les certificats SONT l'AC : un octet de différence et plus rien
|
||||||
|
# de ce qu'elle a signé ne se vérifie. Sa base (badger) se compacte d'elle-même.
|
||||||
|
identite = lambda rel: rel.startswith(("certs/", "secrets/")) # noqa: E731
|
||||||
|
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=identite,
|
||||||
|
perte_stricte=identite)
|
||||||
|
elif jeu == "icinga":
|
||||||
|
# L'AC d'Icinga et l'environnement qu'elle derive : changes, chaque identifiant
|
||||||
|
# d'Icinga DB change, et tout l'historique restaure devient orphelin.
|
||||||
|
identite = lambda rel: rel in ("ca.crt", "ca.key", "icingadb.env") # noqa: E731
|
||||||
|
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=identite,
|
||||||
|
perte_stricte=identite)
|
||||||
|
elif jeu == "courriel":
|
||||||
|
comparer_arbres(r, arbre(a_avant, maildir), arbre(a_vivant, maildir),
|
||||||
|
strict=jamais, perte_stricte=est_message)
|
||||||
|
elif jeu == "rspamd":
|
||||||
|
# La clé DKIM est l'identité du domaine ; le bayes, lui, apprend tous les jours.
|
||||||
|
dkim = lambda rel: rel.startswith("dkim/") # noqa: E731
|
||||||
|
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=dkim, perte_stricte=dkim)
|
||||||
|
elif jeu == "nextcloud":
|
||||||
|
if c.rstrip("/").endswith("/config"):
|
||||||
|
instance_id(r, a_avant / "config.php", a_vivant / "config.php")
|
||||||
|
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=jamais,
|
||||||
|
perte_stricte=jamais)
|
||||||
|
else:
|
||||||
|
# Les fichiers des PERSONNES ; `appdata_*` est un cache que Nextcloud refait.
|
||||||
|
fichier = lambda rel: re.match(r"^(?!appdata_)[^/]+/files/", rel) is not None # noqa: E731
|
||||||
|
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=jamais,
|
||||||
|
perte_stricte=fichier)
|
||||||
|
else:
|
||||||
|
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=jamais,
|
||||||
|
perte_stricte=toujours)
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def instance_id(r: Rapport, avant: Path, vivant: Path) -> None:
|
||||||
|
def lire(p: Path) -> str:
|
||||||
|
m = re.search(r"'instanceid'\s*=>\s*'([^']+)'", p.read_text(errors="replace")) if p.is_file() else None
|
||||||
|
return m.group(1) if m else ""
|
||||||
|
a, v = lire(avant), lire(vivant)
|
||||||
|
if a and a != v:
|
||||||
|
r.ecarts.append(f"IDENTITE instanceid : {a} -> {v or '(absent)'}")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Les bases copiees a froid : Prometheus et Loki (2026-10-07) ----------------------
|
||||||
|
#
|
||||||
|
# Leurs FICHIERS ne se comparent pas : Prometheus fusionne ses blocs, Loki compacte son
|
||||||
|
# index, et la retention (15 j, 31 j) en retire chaque jour. On compare ce qui compte.
|
||||||
|
|
||||||
|
def blocs_prometheus(racine: Path) -> list[tuple[int, int]]:
|
||||||
|
out = []
|
||||||
|
for meta in racine.glob("01*/meta.json") if racine.is_dir() else []:
|
||||||
|
try:
|
||||||
|
m = json.loads(meta.read_text())
|
||||||
|
out.append((int(m["minTime"]), int(m["maxTime"])))
|
||||||
|
except (ValueError, KeyError):
|
||||||
|
continue
|
||||||
|
return sorted(out)
|
||||||
|
|
||||||
|
|
||||||
|
def segments_wal(racine: Path) -> list[str]:
|
||||||
|
return sorted(p.name for p in (racine / "wal").glob("[0-9]*")) if (racine / "wal").is_dir() else []
|
||||||
|
|
||||||
|
|
||||||
|
def jeu_prometheus(avant: Path, vivant: Path) -> Rapport:
|
||||||
|
"""Les métriques vivantes couvrent-elles encore l'avant-rasage ?
|
||||||
|
|
||||||
|
Le repère : le DÉBUT du bloc le plus récent de l'instantané — quelques heures avant le
|
||||||
|
rasage. Un bloc vivant doit couvrir cet instant. La rétention ne l'a pas retiré (il a
|
||||||
|
moins de 15 jours) ; une base qui n'a pas été restaurée ne peut pas le couvrir : elle
|
||||||
|
est née après la reconstruction. Une base de moins de deux heures n'a pas encore de
|
||||||
|
bloc : son WAL fait alors le repère, et doit se retrouver dans la base vivante, ou y
|
||||||
|
avoir été fermé en bloc.
|
||||||
|
"""
|
||||||
|
r = Rapport("prometheus")
|
||||||
|
a, v = blocs_prometheus(avant), blocs_prometheus(vivant)
|
||||||
|
r.compte = len(a)
|
||||||
|
if a:
|
||||||
|
t = max(debut for debut, _ in a)
|
||||||
|
if not any(debut <= t < fin for debut, fin in v):
|
||||||
|
r.ecarts.append(f"aucun bloc vivant ne couvre {t} (debut du dernier bloc d'avant) : "
|
||||||
|
f"metriques d'avant non restaurees")
|
||||||
|
if v and min(d for d, _ in v) > min(d for d, _ in a):
|
||||||
|
r.changes.append(f"plus ancienne donnee : {min(d for d, _ in a)} -> {min(d for d, _ in v)} (retention)")
|
||||||
|
else:
|
||||||
|
wal = segments_wal(avant)
|
||||||
|
if wal and not v and wal[0] not in segments_wal(vivant):
|
||||||
|
r.ecarts.append(f"le WAL d'avant ({wal[0]}...) n'est pas dans la base vivante, et elle n'a "
|
||||||
|
f"aucun bloc : metriques d'avant non restaurees")
|
||||||
|
if len(v) != len(a):
|
||||||
|
r.changes.append(f"{len(a)} bloc(s) avant, {len(v)} maintenant (fusions, retention, nouveaux)")
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def jeu_loki(avant: Path, vivant: Path) -> Rapport:
|
||||||
|
"""Des morceaux de journal d'avant subsistent-ils ? Aucun : non restaurés.
|
||||||
|
|
||||||
|
La rétention (31 j) en retire chaque jour : une perte PARTIELLE est listée. Une perte
|
||||||
|
TOTALE, elle, ne vient que d'une base qui n'a pas été remise.
|
||||||
|
"""
|
||||||
|
r = Rapport("loki")
|
||||||
|
a = set(arbre(avant / "chunks"))
|
||||||
|
v = set(arbre(vivant / "chunks"))
|
||||||
|
r.compte = len(a)
|
||||||
|
if a and not (a & v):
|
||||||
|
r.ecarts.append(f"aucun des {len(a)} morceau(x) de journal d'avant ne subsiste : journaux non restaures")
|
||||||
|
elif a - v:
|
||||||
|
r.retires.append(f"{len(a - v)} morceau(x) d'avant retire(s) (retention ?)")
|
||||||
|
if v - a:
|
||||||
|
r.nouveaux.append(f"{len(v - a)} morceau(x) nouveau(x)")
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
# --- L'annuaire ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def entrees_ldif(texte: str) -> dict[str, dict[str, list[str]]]:
|
||||||
|
"""{dn : {attribut : [valeurs triées]}}, attributs opérationnels exclus."""
|
||||||
|
deplie = re.sub(r"\n ", "", texte.replace("\r\n", "\n"))
|
||||||
|
out: dict[str, dict[str, list[str]]] = {}
|
||||||
|
for bloc in deplie.split("\n\n"):
|
||||||
|
attrs: dict[str, list[str]] = {}
|
||||||
|
dn = ""
|
||||||
|
for ligne in bloc.splitlines():
|
||||||
|
if not ligne or ligne.startswith("#") or ":" not in ligne:
|
||||||
|
continue
|
||||||
|
nom, _, val = ligne.partition(":")
|
||||||
|
if val.startswith(":"):
|
||||||
|
val = base64.b64decode(val[1:].strip()).decode("utf-8", "replace")
|
||||||
|
else:
|
||||||
|
val = val.strip()
|
||||||
|
if nom.lower() == "dn":
|
||||||
|
dn = val.lower()
|
||||||
|
elif nom.lower() not in OPERATIONNELS:
|
||||||
|
attrs.setdefault(nom.lower(), []).append(val)
|
||||||
|
if dn:
|
||||||
|
out[dn] = {k: sorted(v) for k, v in attrs.items()}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def jeu_annuaire(avant_ldif: Path, vivant_ldif: Path) -> Rapport:
|
||||||
|
r = Rapport("openldap")
|
||||||
|
if not avant_ldif.is_file():
|
||||||
|
r.ecarts.append(f"l'instantane n'emporte pas d'annuaire ({avant_ldif.name})")
|
||||||
|
return r
|
||||||
|
a = entrees_ldif(avant_ldif.read_text(errors="replace"))
|
||||||
|
v = entrees_ldif(vivant_ldif.read_text(errors="replace")) if vivant_ldif.is_file() else {}
|
||||||
|
r.compte = len(a)
|
||||||
|
for dn, attrs in sorted(a.items()):
|
||||||
|
if dn not in v:
|
||||||
|
r.ecarts.append(f"perdue : {dn}")
|
||||||
|
continue
|
||||||
|
if attrs == v[dn]:
|
||||||
|
continue
|
||||||
|
# Le mot de passe d'une entrée, c'est ce que la reconstruction du 2026-09-30 avait
|
||||||
|
# remis à la valeur d'amorçage sans que rien ne le dise.
|
||||||
|
if attrs.get("userpassword") != v[dn].get("userpassword"):
|
||||||
|
r.ecarts.append(f"IDENTITE userPassword : {dn}")
|
||||||
|
diff = sorted(k for k in set(attrs) | set(v[dn])
|
||||||
|
if k != "userpassword" and attrs.get(k) != v[dn].get(k))
|
||||||
|
if diff:
|
||||||
|
r.changes.append(f"{dn} ({', '.join(diff)})")
|
||||||
|
r.nouveaux.extend(sorted(set(v) - set(a)))
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
# --- PostgreSQL ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def lire_dump(texte: str) -> tuple[set[str], set[str], dict[tuple[str, str, str], list[str]]]:
|
||||||
|
"""(bases, rôles, {(base, table, 1re colonne) : [valeurs]}) d'après un `pg_dumpall`."""
|
||||||
|
bases = set(re.findall(r"^CREATE DATABASE (\S+) ", texte, re.M))
|
||||||
|
roles = set(re.findall(r"^CREATE ROLE (\S+);", texte, re.M))
|
||||||
|
cles: dict[tuple[str, str, str], list[str]] = {}
|
||||||
|
base, table = "", None
|
||||||
|
for ligne in texte.splitlines():
|
||||||
|
if table is not None:
|
||||||
|
if ligne == "\\.":
|
||||||
|
table = None
|
||||||
|
else:
|
||||||
|
cles[table].append(ligne.split("\t", 1)[0])
|
||||||
|
elif ligne.startswith("\\connect "):
|
||||||
|
base = ligne.split()[-1]
|
||||||
|
else:
|
||||||
|
m = re.match(r"^COPY (\S+) \(([^,)]+)[,)].*FROM stdin;$", ligne)
|
||||||
|
if m:
|
||||||
|
table = (base, m.group(1), m.group(2).strip())
|
||||||
|
cles[table] = []
|
||||||
|
return bases, roles, cles
|
||||||
|
|
||||||
|
|
||||||
|
def psql(base: str, sql: str) -> list[str] | None:
|
||||||
|
r = subprocess.run(["runuser", "-u", "postgres", "--", "psql", "-tAX", "-d", base, "-c", sql],
|
||||||
|
capture_output=True, text=True, env=dict(os.environ, PAGER="cat"))
|
||||||
|
return r.stdout.splitlines() if r.returncode == 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def jeu_postgresql(dump: Path, interroger=psql) -> Rapport:
|
||||||
|
"""Par CLÉ, pas par nombre de lignes (2026-10-07).
|
||||||
|
|
||||||
|
Compter ne voyait rien : l'historique d'Icinga DB, entièrement remplacé (412 lignes
|
||||||
|
d'avant, toutes perdues ; 380 neuves), passait pour « 412 -> 380 ». On compare donc la
|
||||||
|
première colonne de chaque table, lue au même format que le dump (`COPY ... TO STDOUT`).
|
||||||
|
|
||||||
|
Ce qui est un ÉCART, d'après la mesure sur Chezlepro (avant-raser contre vivant) :
|
||||||
|
- une base dont AUCUNE ligne d'avant ne subsiste, toutes tables confondues : elle n'a
|
||||||
|
pas été restaurée (c'était icingadb, ses 30 tables renouvelées) ;
|
||||||
|
- une ligne perdue dans une table d'HISTORIQUE (`*history*`) : par nature, on y
|
||||||
|
ajoute, on n'y retire pas.
|
||||||
|
Le reste est listé : la découverte de grappe de Keycloak (`jgroups_ping`), ses sessions,
|
||||||
|
une configuration de composant régénérée renouvellent leurs clés dans la vie ordinaire.
|
||||||
|
"""
|
||||||
|
r = Rapport("postgresql")
|
||||||
|
if not dump.is_file():
|
||||||
|
r.ecarts.append(f"l'instantane n'emporte pas de dump ({dump.name})")
|
||||||
|
return r
|
||||||
|
bases, roles, cles = lire_dump(dump.read_text(errors="replace"))
|
||||||
|
vivantes = set(interroger("postgres", "select datname from pg_database") or [])
|
||||||
|
roles_vivants = set(interroger("postgres", "select rolname from pg_roles") or [])
|
||||||
|
for b in sorted(bases - vivantes):
|
||||||
|
r.ecarts.append(f"base perdue : {b}")
|
||||||
|
for x in sorted(roles - roles_vivants):
|
||||||
|
r.ecarts.append(f"role perdu : {x}")
|
||||||
|
par_base: dict[str, list[int]] = collections.defaultdict(lambda: [0, 0])
|
||||||
|
for (b, t, col), avant in sorted(cles.items()):
|
||||||
|
if b not in vivantes or not avant:
|
||||||
|
continue
|
||||||
|
r.compte += 1
|
||||||
|
vivant = interroger(b, f"COPY (select {col} from {t}) TO STDOUT")
|
||||||
|
if vivant is None:
|
||||||
|
r.ecarts.append(f"table perdue : {b}.{t}")
|
||||||
|
continue
|
||||||
|
a, v = set(avant), set(vivant)
|
||||||
|
communes = len(a & v)
|
||||||
|
par_base[b][0] += len(a)
|
||||||
|
par_base[b][1] += communes
|
||||||
|
perdues, nouvelles = len(a) - communes, len(v - a)
|
||||||
|
if perdues and "history" in t:
|
||||||
|
r.ecarts.append(f"historique perdu : {b}.{t} : {perdues} ligne(s) d'avant sur {len(a)}")
|
||||||
|
elif perdues or nouvelles:
|
||||||
|
r.changes.append(f"{b}.{t} : {perdues} retiree(s), {nouvelles} nouvelle(s) (avant {len(a)})")
|
||||||
|
for b, (n, communes) in sorted(par_base.items()):
|
||||||
|
if n and not communes:
|
||||||
|
r.ecarts.insert(0, f"base {b} : aucune des {n} ligne(s) d'avant ne subsiste — non restauree")
|
||||||
|
# `template0` n'est jamais dans un `pg_dumpall` : il n'est pas « nouveau », il est hors jeu.
|
||||||
|
r.nouveaux.extend(f"base {b}" for b in sorted(vivantes - bases - {"postgres", "template0"}))
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
# --- Programme -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None, interroger=psql) -> int:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
ap.add_argument("--avant", required=True, type=Path, help="racine où l'instantané est extrait")
|
||||||
|
ap.add_argument("--instantane", required=True)
|
||||||
|
ap.add_argument("--heure", default="")
|
||||||
|
ap.add_argument("--hote", default="")
|
||||||
|
ap.add_argument("--jeu", action="append", default=[], help="NOM=CHEMIN[:CHEMIN...]")
|
||||||
|
ap.add_argument("--vivant", action="append", default=[],
|
||||||
|
help="NOM=CHEMIN : ou vit la base d'un jeu copie a froid")
|
||||||
|
ap.add_argument("--ldif", default="", help="chemin du LDIF dans l'instantané")
|
||||||
|
ap.add_argument("--ldif-vivant", type=Path, default=None)
|
||||||
|
ap.add_argument("--dump", default="", help="chemin du pg_dumpall dans l'instantané")
|
||||||
|
a = ap.parse_args(argv)
|
||||||
|
|
||||||
|
print(f"TEMOINS de {a.hote} : instantane {a.instantane} ({a.heure}) contre l'etat vivant")
|
||||||
|
if not a.jeu:
|
||||||
|
print(" SANS OBJET : ce noeud ne detient aucun etat.")
|
||||||
|
return 0
|
||||||
|
ecart = False
|
||||||
|
vivants = dict(v.partition("=")[::2] for v in a.vivant)
|
||||||
|
for spec in a.jeu:
|
||||||
|
nom, _, chemins = spec.partition("=")
|
||||||
|
# La copie a froid est le SEUL sous-dossier de la preparation du jeu.
|
||||||
|
copie = lambda: next((d for d in sorted((a.avant / chemins.split(":")[0].lstrip("/")).glob("*")) # noqa: E731
|
||||||
|
if d.is_dir()), a.avant / "absent")
|
||||||
|
if nom == "prometheus" and nom in vivants:
|
||||||
|
r, unite = jeu_prometheus(copie(), Path(vivants[nom])), "bloc(s)"
|
||||||
|
elif nom == "loki" and nom in vivants:
|
||||||
|
r, unite = jeu_loki(copie(), Path(vivants[nom])), "morceau(x)"
|
||||||
|
elif nom == "openldap":
|
||||||
|
r, unite = jeu_annuaire(a.avant / a.ldif.lstrip("/"), a.ldif_vivant or Path("/nonexistent")), "entree(s)"
|
||||||
|
elif nom == "postgresql":
|
||||||
|
r, unite = jeu_postgresql(a.avant / a.dump.lstrip("/"), interroger), "table(s)"
|
||||||
|
else:
|
||||||
|
r, unite = jeu_fichiers(nom, [c for c in chemins.split(":") if c], a.avant), "fichier(s)"
|
||||||
|
print("\n".join(r.lignes(unite)))
|
||||||
|
ecart = ecart or bool(r.ecarts)
|
||||||
|
print("ECART : une perte, ou une identite qui a change." if ecart
|
||||||
|
else "Aucun ecart : rien de perdu, l'identite est intacte.")
|
||||||
|
return 1 if ecart else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
|
@ -2,3 +2,36 @@
|
||||||
- name: Recharger systemd
|
- name: Recharger systemd
|
||||||
ansible.builtin.systemd:
|
ansible.builtin.systemd:
|
||||||
daemon_reload: true
|
daemon_reload: true
|
||||||
|
|
||||||
|
# PREMIER RAPPORT A UN ICINGA QUI NE NOUS A JAMAIS ENTENDUS — voir `tasks/verifier.yml`.
|
||||||
|
# Trois temps, puis la trace, dans cet ordre (les gestionnaires s'executent dans l'ordre de ce fichier) :
|
||||||
|
# deposer, pour que le depot porte un instantane de CETTE incarnation ; verifier le depot ;
|
||||||
|
# verifier qu'il se restaure. Les unites sont `oneshot` : chacune rend la main une fois son
|
||||||
|
# rapport parti, et echoue — bruyamment — si la sauvegarde refuse (etat d'avant non remis)
|
||||||
|
# ou si Icinga ne prend pas le resultat.
|
||||||
|
- name: Premier depot
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: setops-sauvegarde.service
|
||||||
|
state: started
|
||||||
|
listen: Premier rapport a Icinga
|
||||||
|
|
||||||
|
- name: Premier controle du depot
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: setops-verification-depot.service
|
||||||
|
state: started
|
||||||
|
listen: Premier rapport a Icinga
|
||||||
|
|
||||||
|
- name: Premier controle de restauration
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: setops-verification-restauration.service
|
||||||
|
state: started
|
||||||
|
listen: Premier rapport a Icinga
|
||||||
|
|
||||||
|
- name: Retenir l'Icinga qui nous a entendus
|
||||||
|
ansible.builtin.copy:
|
||||||
|
content: "{{ client_backup_icinga_empreinte }}\n"
|
||||||
|
dest: "{{ client_backup_marque_icinga }}"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0600"
|
||||||
|
listen: Premier rapport a Icinga
|
||||||
|
|
|
||||||
78
roles/client_backup/tasks/acces.yml
Normal file
78
roles/client_backup/tasks/acces.yml
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
---
|
||||||
|
# ACCES AU DEPOT DE CE NOEUD — partage par la sauvegarde (`main.yml`) et par la
|
||||||
|
# restauration (`restaurer.yml`, incluse par les roles proprietaires de l'etat).
|
||||||
|
#
|
||||||
|
# Une seule copie de ces taches : deux facons de poser une cle finiraient par diverger,
|
||||||
|
# et on ne le decouvrirait que le jour ou l'on restaure.
|
||||||
|
- name: Installer restic
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: "{{ client_backup_paquets }}"
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
|
||||||
|
- name: Créer le répertoire des secrets Set-OPS
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /etc/setops
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0700"
|
||||||
|
|
||||||
|
- name: Déposer la clé SSH privée de sauvegarde
|
||||||
|
ansible.builtin.copy:
|
||||||
|
content: "{{ client_backup_ssh_privkey }}"
|
||||||
|
dest: "{{ client_backup_ssh_key }}"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0600"
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Déposer le mot de passe restic
|
||||||
|
ansible.builtin.copy:
|
||||||
|
content: "{{ client_backup_password }}\n"
|
||||||
|
dest: /etc/setops/restic.pass
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0600"
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Assurer /root/.ssh
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /root/.ssh
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0700"
|
||||||
|
|
||||||
|
- name: Configurer l'accès SSH à la cible de sauvegarde
|
||||||
|
ansible.builtin.blockinfile:
|
||||||
|
path: /root/.ssh/config
|
||||||
|
create: true
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0600"
|
||||||
|
marker: "# {mark} SETOPS BACKUP {{ client_backup_cible }}"
|
||||||
|
block: |
|
||||||
|
Host {{ client_backup_cible }}
|
||||||
|
User {{ client_backup_utilisateur_distant }}
|
||||||
|
IdentityFile {{ client_backup_ssh_key }}
|
||||||
|
IdentitiesOnly yes
|
||||||
|
StrictHostKeyChecking accept-new
|
||||||
|
|
||||||
|
- name: Déployer l'outil de restauration (setops-restaurer)
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: restaurer.sh.j2
|
||||||
|
dest: /usr/local/sbin/setops-restaurer
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0700"
|
||||||
|
|
||||||
|
# LES TEMOINS (2026-10-07) : `setops-restaurer temoins` extrait l'instantane d'avant rasage,
|
||||||
|
# puis ce comparateur le confronte a l'etat vivant. Lecture seule.
|
||||||
|
- name: Déployer le comparateur des témoins (setops-temoins)
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: setops-temoins.py
|
||||||
|
dest: /usr/local/sbin/setops-temoins
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0700"
|
||||||
31
roles/client_backup/tasks/acter.yml
Normal file
31
roles/client_backup/tasks/acter.yml
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
---
|
||||||
|
# POSER LE MARQUEUR D'UN JEU — APRES le geste, jamais avant : ecrit d'abord, une
|
||||||
|
# restauration ratee laisserait le noeud convaincu d'avoir remis un etat qu'il n'a pas,
|
||||||
|
# et la sauvegarde recommencerait a deposer par-dessus.
|
||||||
|
#
|
||||||
|
# entree : client_backup_acter_etat (restaure, neuf, en_place, desactive, abandonne)
|
||||||
|
# client_backup_restauration (le jeu, et l'instantane s'il y en a un)
|
||||||
|
- name: Restauration — repertoire des marqueurs
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ client_backup_restauration_marques }}"
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0700"
|
||||||
|
when: not ansible_check_mode
|
||||||
|
|
||||||
|
- name: Restauration — acter le jeu
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "{{ client_backup_restauration_marques }}/{{ client_backup_restauration.jeu }}"
|
||||||
|
content: |
|
||||||
|
etat={{ client_backup_acter_etat }}
|
||||||
|
instantane={{ client_backup_restauration.instantane | default('') }}
|
||||||
|
date={{ now(utc=true).isoformat() }}
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0600"
|
||||||
|
when: not ansible_check_mode
|
||||||
|
|
||||||
|
- name: Restauration — retenir l'etat acte
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
client_backup_restauration: "{{ client_backup_restauration | combine({'etat': client_backup_acter_etat}) }}"
|
||||||
|
|
@ -34,60 +34,10 @@
|
||||||
+ (client_backup_jobs | map(attribute='nom') | join(', ')),
|
+ (client_backup_jobs | map(attribute='nom') | join(', ')),
|
||||||
'aucun etat non regenerable — aucune sauvegarde installee') }}
|
'aucun etat non regenerable — aucune sauvegarde installee') }}
|
||||||
|
|
||||||
- name: Installer restic
|
# L'ACCES AU DEPOT vit a part : les roles proprietaires l'incluent aussi, pour RESTAURER
|
||||||
ansible.builtin.apt:
|
# avant meme que ce role-ci ait tourne (l'AC se restaure dans la couche `pki_racine`).
|
||||||
name: "{{ client_backup_paquets }}"
|
- name: Accès au dépôt (restic, clé, mot de passe, SSH) et outil de restauration
|
||||||
state: present
|
ansible.builtin.include_tasks: acces.yml
|
||||||
update_cache: true
|
|
||||||
|
|
||||||
- name: Créer le répertoire des secrets Set-OPS
|
|
||||||
ansible.builtin.file:
|
|
||||||
path: /etc/setops
|
|
||||||
state: directory
|
|
||||||
owner: root
|
|
||||||
group: root
|
|
||||||
mode: "0700"
|
|
||||||
|
|
||||||
- name: Déposer la clé SSH privée de sauvegarde
|
|
||||||
ansible.builtin.copy:
|
|
||||||
content: "{{ client_backup_ssh_privkey }}"
|
|
||||||
dest: "{{ client_backup_ssh_key }}"
|
|
||||||
owner: root
|
|
||||||
group: root
|
|
||||||
mode: "0600"
|
|
||||||
no_log: true
|
|
||||||
|
|
||||||
- name: Déposer le mot de passe restic
|
|
||||||
ansible.builtin.copy:
|
|
||||||
content: "{{ client_backup_password }}\n"
|
|
||||||
dest: /etc/setops/restic.pass
|
|
||||||
owner: root
|
|
||||||
group: root
|
|
||||||
mode: "0600"
|
|
||||||
no_log: true
|
|
||||||
|
|
||||||
- name: Assurer /root/.ssh
|
|
||||||
ansible.builtin.file:
|
|
||||||
path: /root/.ssh
|
|
||||||
state: directory
|
|
||||||
owner: root
|
|
||||||
group: root
|
|
||||||
mode: "0700"
|
|
||||||
|
|
||||||
- name: Configurer l'accès SSH à la cible de sauvegarde
|
|
||||||
ansible.builtin.blockinfile:
|
|
||||||
path: /root/.ssh/config
|
|
||||||
create: true
|
|
||||||
owner: root
|
|
||||||
group: root
|
|
||||||
mode: "0600"
|
|
||||||
marker: "# {mark} SETOPS BACKUP {{ client_backup_cible }}"
|
|
||||||
block: |
|
|
||||||
Host {{ client_backup_cible }}
|
|
||||||
User {{ client_backup_utilisateur_distant }}
|
|
||||||
IdentityFile {{ client_backup_ssh_key }}
|
|
||||||
IdentitiesOnly yes
|
|
||||||
StrictHostKeyChecking accept-new
|
|
||||||
|
|
||||||
- name: Assurer le répertoire de préparation des dumps
|
- name: Assurer le répertoire de préparation des dumps
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
|
|
@ -106,6 +56,17 @@
|
||||||
group: root
|
group: root
|
||||||
mode: "0700"
|
mode: "0700"
|
||||||
|
|
||||||
|
# LA COPIE A FROID (2026-10-07) : les jeux qui s'ecrivent en continu (Prometheus, Loki)
|
||||||
|
# arretent leur service le temps de copier. L'outil est deploye avec le script qui l'appelle.
|
||||||
|
- name: Déployer l'outil de copie à froid
|
||||||
|
when: client_backup_jobs | length > 0
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: setops-copie-a-froid.sh
|
||||||
|
dest: /usr/local/sbin/setops-copie-a-froid
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0700"
|
||||||
|
|
||||||
- name: Déployer l'unité et le timer systemd
|
- name: Déployer l'unité et le timer systemd
|
||||||
when: client_backup_jobs | length > 0
|
when: client_backup_jobs | length > 0
|
||||||
ansible.builtin.template:
|
ansible.builtin.template:
|
||||||
|
|
@ -117,6 +78,7 @@
|
||||||
loop:
|
loop:
|
||||||
- { s: setops-sauvegarde.service.j2, d: setops-sauvegarde.service }
|
- { s: setops-sauvegarde.service.j2, d: setops-sauvegarde.service }
|
||||||
- { s: setops-sauvegarde.timer.j2, d: setops-sauvegarde.timer }
|
- { s: setops-sauvegarde.timer.j2, d: setops-sauvegarde.timer }
|
||||||
|
- { s: setops-sauvegarde-avant-raser.service.j2, d: setops-sauvegarde-avant-raser.service }
|
||||||
|
|
||||||
- name: Activer le timer de sauvegarde
|
- name: Activer le timer de sauvegarde
|
||||||
when:
|
when:
|
||||||
|
|
@ -140,6 +102,24 @@
|
||||||
state: stopped
|
state: stopped
|
||||||
failed_when: false
|
failed_when: false
|
||||||
|
|
||||||
|
# LES VERIFICATIONS PARTENT AVEC LA SAUVEGARDE (2026-09-30). Retirees seules, les unites de
|
||||||
|
# sauvegarde laissaient derriere elles celles qui VERIFIENT le depot et la restauration :
|
||||||
|
# elles continuaient de rapporter a des services qu'Icinga ne declare plus (le noeud n'est
|
||||||
|
# plus detenteur d'etat) — un 404 toutes les quatre heures, et une unite rouge. Vu en
|
||||||
|
# retirant le web frontal du catalogue.
|
||||||
|
- name: Arreter les verifications d'une sauvegarde devenue vide
|
||||||
|
when:
|
||||||
|
- client_backup_jobs | length == 0
|
||||||
|
- not ansible_check_mode
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: "{{ item }}"
|
||||||
|
enabled: false
|
||||||
|
state: stopped
|
||||||
|
loop:
|
||||||
|
- setops-verification-depot.timer
|
||||||
|
- setops-verification-restauration.timer
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
- name: Retirer le script et les unites d'une sauvegarde vide
|
- name: Retirer le script et les unites d'une sauvegarde vide
|
||||||
when: client_backup_jobs | length == 0
|
when: client_backup_jobs | length == 0
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
|
|
@ -147,8 +127,16 @@
|
||||||
state: absent
|
state: absent
|
||||||
loop:
|
loop:
|
||||||
- /usr/local/sbin/setops-sauvegarder.sh
|
- /usr/local/sbin/setops-sauvegarder.sh
|
||||||
|
- /usr/local/sbin/setops-copie-a-froid
|
||||||
- /etc/systemd/system/setops-sauvegarde.service
|
- /etc/systemd/system/setops-sauvegarde.service
|
||||||
- /etc/systemd/system/setops-sauvegarde.timer
|
- /etc/systemd/system/setops-sauvegarde.timer
|
||||||
|
- /etc/systemd/system/setops-sauvegarde-avant-raser.service
|
||||||
|
- /usr/local/sbin/setops-verifier-mon-depot.sh
|
||||||
|
- /usr/local/sbin/setops-verifier-restauration.sh
|
||||||
|
- /etc/systemd/system/setops-verification-depot.service
|
||||||
|
- /etc/systemd/system/setops-verification-depot.timer
|
||||||
|
- /etc/systemd/system/setops-verification-restauration.service
|
||||||
|
- /etc/systemd/system/setops-verification-restauration.timer
|
||||||
notify: Recharger systemd
|
notify: Recharger systemd
|
||||||
|
|
||||||
# --- LE NOEUD VERIFIE SON PROPRE DEPOT ----------------------------------------
|
# --- LE NOEUD VERIFIE SON PROPRE DEPOT ----------------------------------------
|
||||||
|
|
|
||||||
98
roles/client_backup/tasks/restaurer.yml
Normal file
98
roles/client_backup/tasks/restaurer.yml
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
---
|
||||||
|
# L'ETAT D'UNE INCARNATION PRECEDENTE ATTEND-IL CE JEU ?
|
||||||
|
#
|
||||||
|
# Inclus par le role PROPRIETAIRE de l'etat, au moment precis ou il le creerait neuf :
|
||||||
|
# l'AC avant `step ca init`, les bases juste creees, l'annuaire une fois son schema pose...
|
||||||
|
# C'est lui qui sait quand son etat est « vierge » ; ce fichier sait ou est le depot.
|
||||||
|
#
|
||||||
|
# entree : client_backup_restaurer_jeu — groupe du catalogue (ex. serveur_step_ca)
|
||||||
|
# client_backup_restaurer_vierge — le role n'a encore RIEN de cet etat
|
||||||
|
# sortie : client_backup_restauration — {jeu, etat, instantane, chemins}
|
||||||
|
#
|
||||||
|
# etat : `a_restaurer` (au role de le remettre, puis d'inclure `acter.yml`), `acte`
|
||||||
|
# (deja tranche a une passe precedente), `en_place` (un etat existe : on ne l'ecrase
|
||||||
|
# jamais d'office), `neuf` (rien d'anterieur), `sans_objet`, `simulation`.
|
||||||
|
- name: Restauration — le jeu et ce qu'il emporte
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
client_backup_restauration:
|
||||||
|
jeu: "{{ client_backup_catalogue[client_backup_restaurer_jeu].nom }}"
|
||||||
|
chemins: "{{ client_backup_catalogue[client_backup_restaurer_jeu].chemins }}"
|
||||||
|
etat: >-
|
||||||
|
{{ 'simulation' if ansible_check_mode
|
||||||
|
else ('sans_objet' if ('client_backup' not in group_names)
|
||||||
|
else ('desactive' if not (client_backup_restauration_active | bool)
|
||||||
|
else 'a_decider')) }}
|
||||||
|
instantane: ""
|
||||||
|
|
||||||
|
- name: Restauration — trancher pour ce jeu
|
||||||
|
when: client_backup_restauration.etat != 'sans_objet' and client_backup_restauration.etat != 'simulation'
|
||||||
|
block:
|
||||||
|
- name: Restauration — ce jeu a-t-il deja ete tranche ?
|
||||||
|
ansible.builtin.slurp:
|
||||||
|
src: "{{ client_backup_restauration_marques }}/{{ client_backup_restauration.jeu }}"
|
||||||
|
register: client_backup_marque_lue
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Restauration — deja tranche a une passe precedente
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
client_backup_restauration: >-
|
||||||
|
{{ client_backup_restauration | combine({'etat': 'acte',
|
||||||
|
'instantane': ((client_backup_marque_lue.content | b64decode).splitlines()
|
||||||
|
| select('match', '^instantane=') | first
|
||||||
|
| default('instantane=')).split('=', 1)[1]}) }}
|
||||||
|
when: client_backup_marque_lue.content is defined
|
||||||
|
|
||||||
|
- name: "Restauration — desactivee, ou un etat existe deja, l'acter tel quel"
|
||||||
|
ansible.builtin.include_tasks: acter.yml
|
||||||
|
vars:
|
||||||
|
client_backup_acter_etat: "{{ 'desactive' if client_backup_restauration.etat == 'desactive' else 'en_place' }}"
|
||||||
|
when:
|
||||||
|
- client_backup_marque_lue.content is not defined
|
||||||
|
- client_backup_restauration.etat == 'desactive' or not (client_backup_restaurer_vierge | bool)
|
||||||
|
|
||||||
|
# LA CONDITION D'UN BLOC SE REEVALUE A CHAQUE TACHE. Ecrite sur `etat`, elle se
|
||||||
|
# fermerait des que le choix le change — et « rien d'anterieur » ne serait jamais acte.
|
||||||
|
# On la fige donc avant d'entrer.
|
||||||
|
- name: Restauration — faut-il interroger le depot ?
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
client_backup_interroger: >-
|
||||||
|
{{ client_backup_marque_lue.content is not defined
|
||||||
|
and client_backup_restauration.etat == 'a_decider'
|
||||||
|
and (client_backup_restaurer_vierge | bool) }}
|
||||||
|
|
||||||
|
- name: Restauration — interroger le depot
|
||||||
|
when: client_backup_interroger | bool
|
||||||
|
block:
|
||||||
|
- name: Restauration — acces au depot (le role client_backup n'a peut-etre pas encore tourne)
|
||||||
|
ansible.builtin.include_tasks: acces.yml
|
||||||
|
|
||||||
|
# Code 2 = depot ILLISIBLE : on echoue. Conclure « rien a restaurer » faute de
|
||||||
|
# joindre le depot ferait naitre neuf ce qui attend chez l'hebergeur.
|
||||||
|
- name: Restauration — l'instantane de l'incarnation precedente
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv: >-
|
||||||
|
{{ ['/usr/local/sbin/setops-restaurer', 'choisir']
|
||||||
|
+ (['--instantane', client_backup_restauration_instantane]
|
||||||
|
if client_backup_restauration_instantane | length > 0 else []) }}
|
||||||
|
register: client_backup_choix
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Restauration — retenir le choix
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
client_backup_restauration: >-
|
||||||
|
{{ client_backup_restauration | combine({
|
||||||
|
'etat': ('a_restaurer' if (client_backup_choix.stdout | from_json).instantane else 'neuf'),
|
||||||
|
'instantane': (client_backup_choix.stdout | from_json).instantane}) }}
|
||||||
|
|
||||||
|
- name: "Restauration — rien d'anterieur, naissance"
|
||||||
|
ansible.builtin.include_tasks: acter.yml
|
||||||
|
vars:
|
||||||
|
client_backup_acter_etat: neuf
|
||||||
|
when: client_backup_restauration.etat == 'neuf'
|
||||||
|
|
||||||
|
- name: Restauration — verdict
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: >-
|
||||||
|
{{ client_backup_restauration.jeu }} : {{ client_backup_restauration.etat }}{{
|
||||||
|
(' — instantane ' + client_backup_restauration.instantane)
|
||||||
|
if client_backup_restauration.instantane | length > 0 else '' }}
|
||||||
|
|
@ -46,6 +46,45 @@
|
||||||
group: root
|
group: root
|
||||||
mode: "0644"
|
mode: "0644"
|
||||||
|
|
||||||
|
# UN ICINGA QUI NE NOUS A JAMAIS ENTENDUS (2026-09-30). Ses services `sauvegarde` et
|
||||||
|
# `restauration` sont CRITIQUES tant qu'aucun rapport n'est arrive — c'est voulu, le silence
|
||||||
|
# doit alerter. Mais les minuteurs ne rapportent que toutes les 4 h (depot) et le DIMANCHE
|
||||||
|
# (restauration) : apres chaque reconstruction, Icinga restait rouge jusqu'a une semaine.
|
||||||
|
#
|
||||||
|
# Le noeud ne peut pas demander a Icinga s'il l'a deja entendu : son compte ne sait que
|
||||||
|
# DEPOSER. Il retient donc LUI-MEME a quel Icinga il a rapporte — l'empreinte de son AC. Un
|
||||||
|
# Icinga refait a une AC neuve ; un noeud refait n'a pas de marqueur.
|
||||||
|
#
|
||||||
|
# UN MARQUEUR A LUI, PAS LA COPIE DE L'AC. La premiere version se fiait au changement de
|
||||||
|
# `icinga-ca.crt` — or `client_sante` depose le MEME fichier, plus tot dans le deploiement :
|
||||||
|
# sur une flotte neuve il etait deja la, « ok », et rien n'etait notifie. Vu a la
|
||||||
|
# reconstruction de Chezlepro du 2026-09-30 : `restauration` rouge sur sept noeuds, et le
|
||||||
|
# pare-feu arrete. L'epreuve precedente avait joue `client_backup` SEUL, et ne pouvait pas
|
||||||
|
# le voir.
|
||||||
|
#
|
||||||
|
# ECRIT APRES LE RAPPORT (dernier gestionnaire), jamais avant : un rapport rate laisserait
|
||||||
|
# sinon le noeud convaincu d'avoir ete entendu, et la passe suivante ne retenterait rien.
|
||||||
|
- name: Retenir l'empreinte de l'Icinga destinataire
|
||||||
|
when: client_backup_ca_presente.stat.exists
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
client_backup_icinga_empreinte: "{{ client_backup_ca_icinga.content | b64decode | hash('sha256') }}"
|
||||||
|
|
||||||
|
- name: A quel Icinga ce noeud a-t-il deja rapporte ?
|
||||||
|
when: client_backup_ca_presente.stat.exists
|
||||||
|
ansible.builtin.slurp:
|
||||||
|
src: "{{ client_backup_marque_icinga }}"
|
||||||
|
register: client_backup_icinga_entendu
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Cet Icinga ne nous a jamais entendus — premier rapport
|
||||||
|
when:
|
||||||
|
- client_backup_ca_presente.stat.exists
|
||||||
|
- (client_backup_icinga_entendu.content | default('') | b64decode | trim) != client_backup_icinga_empreinte
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: "Icinga {{ client_backup_icinga_empreinte[:12] }} n'a encore rien recu de {{ inventory_hostname }} : rapport immediat."
|
||||||
|
changed_when: true
|
||||||
|
notify: Premier rapport a Icinga
|
||||||
|
|
||||||
- name: Installer curl pour le rapport passif
|
- name: Installer curl pour le rapport passif
|
||||||
ansible.builtin.apt:
|
ansible.builtin.apt:
|
||||||
name: curl
|
name: curl
|
||||||
|
|
|
||||||
417
roles/client_backup/templates/restaurer.sh.j2
Normal file
417
roles/client_backup/templates/restaurer.sh.j2
Normal file
|
|
@ -0,0 +1,417 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# GENERE par Set-OPS (role client_backup). Ne pas editer a la main.
|
||||||
|
#
|
||||||
|
# setops-restaurer — REMETTRE EN SERVICE l'etat d'une incarnation precedente de CE noeud.
|
||||||
|
#
|
||||||
|
# POURQUOI IL EXISTE (2026-09-30). Technolibre a ete rasee et reconstruite par ses
|
||||||
|
# runners : 0 echec, `make valider` vert, « restauration verifiee ». Et le mot de passe de
|
||||||
|
# `sysadmin` etait revenu a celui de l'amorcage. RIEN n'etait revenu : ni l'annuaire, ni
|
||||||
|
# la racine de l'AC, ni les bases, ni les fichiers de Nextcloud. « Restauration verifiee »
|
||||||
|
# voulait dire : restauree dans un repertoire temporaire, lue, jetee. Chaque
|
||||||
|
# reconstruction de Chezlepro etait repartie a vide de la meme facon.
|
||||||
|
#
|
||||||
|
# QUELLE INCARNATION. La cle d'hote SSH nait au clonage (`machine-id`, lui, vient du
|
||||||
|
# gabarit : tous les noeuds portent la meme date). L'instantane a remettre est donc le
|
||||||
|
# DERNIER PRIS AVANT LA NAISSANCE de cette cle — celui de la machine qu'on a rasee.
|
||||||
|
# `--instantane ID` impose un autre choix.
|
||||||
|
#
|
||||||
|
# LE MARQUEUR. Un jeu restaure, abandonne, ou trouve deja en place laisse une trace dans
|
||||||
|
# {{ client_backup_restauration_marques }}/<jeu>. La sauvegarde s'y fie (`garde`) : tant
|
||||||
|
# qu'un etat anterieur n'a ete ni remis ni ecarte, ce noeud REFUSE de deposer — sans quoi
|
||||||
|
# `restic forget --keep-daily` chasserait l'instantane d'avant au profit de l'etat neuf du
|
||||||
|
# meme jour.
|
||||||
|
#
|
||||||
|
# RIEN N'EST ECRASE SANS ETRE MIS DE COTE, dans {{ client_backup_restauration_mise_de_cote }}.
|
||||||
|
#
|
||||||
|
# Sous-commandes :
|
||||||
|
# etat ce qui serait restaure, et les marqueurs
|
||||||
|
# choisir [--instantane ID] (pour Ansible) JSON de l'instantane candidat
|
||||||
|
# fichiers [opts] CHEMIN... remettre des repertoires (rsync --delete)
|
||||||
|
# --proprietaire U:G --remplacer --vers DOSSIER (repetition, rien n'est touche)
|
||||||
|
# --depuis CHEMIN_DANS_L_INSTANTANE (un seul CHEMIN) : ce qui a ete sauvegarde
|
||||||
|
# ailleurs que la ou ca vit (copie a froid)
|
||||||
|
# annuaire [opts] BASE_DN remplacer la base LDAP (slapadd)
|
||||||
|
# --essai (slapadd -u a blanc, rien n'est touche)
|
||||||
|
# base [opts] NOM rejouer une base depuis le pg_dumpall
|
||||||
|
# --remplacer --vers AUTRE_NOM (repetition dans une base jetable)
|
||||||
|
# acter JEU ETAT [INSTANTANE] poser le marqueur (restaure, neuf, en_place, abandonne)
|
||||||
|
# garde (pour la sauvegarde) refuser si un etat attend
|
||||||
|
# temoins [--candidat | --instantane ID] l'etat vivant contre l'instantane d'avant rasage
|
||||||
|
# (par defaut le dernier etiquete « avant-raser ») ; lecture seule ; code 1 = ecart,
|
||||||
|
# 4 = rien a comparer
|
||||||
|
# Options communes : --instantane ID.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
export RESTIC_REPOSITORY="{{ client_backup_repo }}"
|
||||||
|
export RESTIC_PASSWORD_FILE="/etc/setops/restic.pass"
|
||||||
|
# UN VERROU N'EST PAS UNE PANNE (2026-10-04). La sauvegarde, la verification du depot, le
|
||||||
|
# controle de restauration et `setops-restaurer` partagent ce depot ; leurs minuteurs peuvent
|
||||||
|
# partir ensemble (`Persistent=true` rattrape au demarrage tout ce qui a ete manque). Sans
|
||||||
|
# attente, restic refuse aussitot un depot verrouille, et une sonde concluait « aucun
|
||||||
|
# instantane » ou « depot corrompu ». On attend le verrou au lieu de conclure.
|
||||||
|
restic() { command restic --retry-lock {{ client_backup_attente_verrou }} "$@"; }
|
||||||
|
MOI="{{ inventory_hostname }}"
|
||||||
|
MARQUES="{{ client_backup_restauration_marques }}"
|
||||||
|
MISE_DE_COTE="{{ client_backup_restauration_mise_de_cote }}"
|
||||||
|
CLE_HOTE="/etc/ssh/ssh_host_ed25519_key.pub"
|
||||||
|
LDIF="{{ client_backup_staging }}/openldap/annuaire.ldif"
|
||||||
|
DUMP_PG="{{ client_backup_staging }}/postgresql/toutes-bases.sql"
|
||||||
|
JEUX=({% for j in client_backup_jobs %} "{{ j.nom }}"{% endfor %} )
|
||||||
|
# Les chemins de chaque jeu, separes par « : » — ce que les temoins extraient et comparent.
|
||||||
|
declare -A CHEMINS_JEU=({% for j in client_backup_jobs %} ["{{ j.nom }}"]="{{ j.chemins | join(':') }}"{% endfor %} )
|
||||||
|
ETIQUETTE_AVANT_RASER="{{ client_backup_etiquette_avant_raser }}"
|
||||||
|
# Ou vit la base des jeux copies a froid (Prometheus, Loki) : leur instantane porte la
|
||||||
|
# copie, pas la base. Les temoins comparent l'une a l'autre.
|
||||||
|
declare -A VIVANT_JEU=({% for j in client_backup_jobs if j.vivant is defined %} ["{{ j.nom }}"]="{{ j.vivant }}"{% endfor %} )
|
||||||
|
|
||||||
|
dire() { printf '%s\n' "$*" >&2; }
|
||||||
|
mourir() { dire "setops-restaurer: $*"; exit 1; }
|
||||||
|
|
||||||
|
naissance() {
|
||||||
|
local b
|
||||||
|
b=$(stat -c %W "${CLE_HOTE}")
|
||||||
|
if [[ "${b}" -le 0 ]]; then b=$(stat -c %Y "${CLE_HOTE}"); fi
|
||||||
|
printf '%s\n' "${b}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Imprime « ID HEURE » du candidat, ou rien. Code 2 : depot illisible — et c'est une
|
||||||
|
# ERREUR, pas une absence : conclure « rien a restaurer » parce que le dépôt ne repond pas
|
||||||
|
# ferait marquer « neuf » une machine dont l'etat attend chez l'hebergeur.
|
||||||
|
candidat() {
|
||||||
|
local impose="${1:-}" json rc
|
||||||
|
set +e
|
||||||
|
json=$(restic snapshots --host "${MOI}" --json 2>/dev/null)
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
# restic 0.17+ : 10 = le depot n'existe pas. Premiere vie de l'ecosysteme.
|
||||||
|
if (( rc == 10 )); then return 0; fi
|
||||||
|
if (( rc != 0 )); then dire "depot illisible (restic rc=${rc}) : ${RESTIC_REPOSITORY}"; return 2; fi
|
||||||
|
printf '%s' "${json}" | python3 -c '
|
||||||
|
import datetime, json, re, sys
|
||||||
|
naissance, impose = int(sys.argv[1]), sys.argv[2]
|
||||||
|
def t(s):
|
||||||
|
s = re.sub(r"(\.\d{6})\d+", r"\1", s)
|
||||||
|
return datetime.datetime.fromisoformat(re.sub(r"Z$", "+00:00", s)).timestamp()
|
||||||
|
snaps = json.load(sys.stdin) or []
|
||||||
|
if impose:
|
||||||
|
c = [x for x in snaps if impose in (x["short_id"], x["id"])]
|
||||||
|
if not c:
|
||||||
|
sys.exit("instantane impose introuvable pour cet hote : " + impose)
|
||||||
|
else:
|
||||||
|
c = [x for x in snaps if t(x["time"]) < naissance]
|
||||||
|
c.sort(key=lambda x: t(x["time"]))
|
||||||
|
if c:
|
||||||
|
print(c[-1]["short_id"], c[-1]["time"][:19])
|
||||||
|
' "$(naissance)" "${impose}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# « ID HEURE » du dernier instantane etiquete avant rasage, ou rien ; code 2 = depot illisible.
|
||||||
|
avant_raser() {
|
||||||
|
local json rc
|
||||||
|
set +e
|
||||||
|
json=$(restic snapshots --host "${MOI}" --tag "${ETIQUETTE_AVANT_RASER}" --json 2>/dev/null)
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
if (( rc == 10 )); then return 0; fi
|
||||||
|
if (( rc != 0 )); then dire "depot illisible (restic rc=${rc}) : ${RESTIC_REPOSITORY}"; return 2; fi
|
||||||
|
printf '%s' "${json}" | python3 -c '
|
||||||
|
import json, sys
|
||||||
|
import datetime, re
|
||||||
|
def t(x):
|
||||||
|
s = re.sub(r"(\.\d{6})\d+", r"\1", x["time"])
|
||||||
|
return datetime.datetime.fromisoformat(re.sub(r"Z$", "+00:00", s)).timestamp()
|
||||||
|
s = sorted(json.load(sys.stdin) or [], key=t)
|
||||||
|
if s:
|
||||||
|
print(s[-1]["short_id"], s[-1]["time"][:19])
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Instantane a utiliser : impose, sinon le candidat. Vide = rien a restaurer.
|
||||||
|
INSTANTANE=""
|
||||||
|
resoudre_instantane() {
|
||||||
|
local c
|
||||||
|
c=$(candidat "${INSTANTANE}") || exit 2
|
||||||
|
INSTANTANE="${c%% *}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Le repertoire jetable ou l'instantane est extrait. Detruit a la sortie, quoi qu'il arrive.
|
||||||
|
TMP=""
|
||||||
|
trap 'if [[ -n "${TMP}" ]]; then rm -rf "${TMP}"; fi' EXIT
|
||||||
|
|
||||||
|
extraire() { # remplit TMP avec les chemins demandes de l'instantane
|
||||||
|
local inc=()
|
||||||
|
TMP=$(mktemp -d /var/tmp/setops-restauration.XXXXXX)
|
||||||
|
for c in "$@"; do inc+=(--include "${c}"); done
|
||||||
|
restic restore "${INSTANTANE}" --target "${TMP}" "${inc[@]}" >/dev/null \
|
||||||
|
|| mourir "restic restore ${INSTANTANE} a echoue"
|
||||||
|
}
|
||||||
|
|
||||||
|
MIS_DE_COTE=""
|
||||||
|
mettre_de_cote() { # $1 = chemin vivant ; MIS_DE_COTE = ou il est parti
|
||||||
|
MIS_DE_COTE="${MISE_DE_COTE}/$(date +%Y%m%d-%H%M%S)$1"
|
||||||
|
mkdir -p "$(dirname "${MIS_DE_COTE}")"
|
||||||
|
cp -a "$1" "${MIS_DE_COTE}"
|
||||||
|
dire "etat en place mis de cote : ${MIS_DE_COTE}"
|
||||||
|
}
|
||||||
|
|
||||||
|
vide() { # vrai si le chemin est absent, ou un repertoire sans contenu
|
||||||
|
[[ ! -e "$1" ]] || { [[ -d "$1" ]] && [[ -z "$(find "$1" -mindepth 1 -print -quit)" ]]; }
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_fichiers() {
|
||||||
|
local proprio="" remplacer=0 vers="" depuis="" chemins=() src dest n
|
||||||
|
while (( $# )); do
|
||||||
|
case "$1" in
|
||||||
|
--proprietaire) proprio="$2"; shift 2 ;;
|
||||||
|
--remplacer) remplacer=1; shift ;;
|
||||||
|
--vers) vers="$2"; shift 2 ;;
|
||||||
|
--depuis) depuis="$2"; shift 2 ;;
|
||||||
|
--instantane) INSTANTANE="$2"; shift 2 ;;
|
||||||
|
*) chemins+=("$1"); shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[[ -n "${chemins[*]:-}" ]] || mourir "fichiers : aucun chemin"
|
||||||
|
# LA COPIE A FROID (2026-10-07) : l'instantane porte la copie posee dans le repertoire de
|
||||||
|
# preparation, pas la base. `--depuis` dit ou la prendre ; le CHEMIN, ou la remettre.
|
||||||
|
# (Un second element, plutot qu'une longueur : la sequence accolade-diese ouvre un
|
||||||
|
# commentaire Jinja dans ce gabarit.)
|
||||||
|
if [[ -n "${depuis}" && -n "${chemins[1]:-}" ]]; then mourir "fichiers --depuis : un seul chemin"; fi
|
||||||
|
resoudre_instantane
|
||||||
|
if [[ -z "${INSTANTANE}" ]]; then echo "RIEN : aucune incarnation precedente."; return 0; fi
|
||||||
|
if [[ -n "${depuis}" ]]; then extraire "${depuis}"; else extraire "${chemins[@]}"; fi
|
||||||
|
for c in "${chemins[@]}"; do
|
||||||
|
src="${TMP}${depuis:-${c}}"
|
||||||
|
dest="${vers}${c}"
|
||||||
|
if [[ ! -e "${src}" ]]; then echo "ABSENT de ${INSTANTANE} : ${c}"; continue; fi
|
||||||
|
if ! vide "${dest}"; then
|
||||||
|
if [[ -z "${vers}" ]] && (( ! remplacer )); then
|
||||||
|
mourir "${dest} n'est pas vide : --remplacer pour l'ecraser (il sera mis de cote)"
|
||||||
|
fi
|
||||||
|
mettre_de_cote "${dest}"
|
||||||
|
fi
|
||||||
|
mkdir -p "$(dirname "${dest}")"
|
||||||
|
if [[ -d "${src}" ]]; then
|
||||||
|
mkdir -p "${dest}"
|
||||||
|
rsync -a --delete "${src}/" "${dest}/"
|
||||||
|
else
|
||||||
|
cp -a "${src}" "${dest}"
|
||||||
|
fi
|
||||||
|
if [[ -n "${proprio}" ]]; then chown -R "${proprio}" "${dest}"; fi
|
||||||
|
n=$(find "${dest}" -type f | wc -l)
|
||||||
|
echo "RESTAURE ${INSTANTANE} : ${c} -> ${dest} (${n} fichier(s))"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_annuaire() {
|
||||||
|
local essai=0 base="" ldif dir n
|
||||||
|
while (( $# )); do
|
||||||
|
case "$1" in
|
||||||
|
--essai) essai=1; shift ;;
|
||||||
|
--instantane) INSTANTANE="$2"; shift 2 ;;
|
||||||
|
*) base="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[[ -n "${base}" ]] || mourir "annuaire : BASE_DN requis"
|
||||||
|
resoudre_instantane
|
||||||
|
if [[ -z "${INSTANTANE}" ]]; then echo "RIEN : aucune incarnation precedente."; return 0; fi
|
||||||
|
extraire "$(dirname "${LDIF}")"
|
||||||
|
ldif="${TMP}${LDIF}"
|
||||||
|
[[ -s "${ldif}" ]] || mourir "${INSTANTANE} n'emporte pas d'annuaire (${LDIF})"
|
||||||
|
n=$(grep -c '^dn:' "${ldif}")
|
||||||
|
# A BLANC D'ABORD : un LDIF que le schema EN PLACE refuse (overlay pas encore charge,
|
||||||
|
# attribut inconnu) doit echouer ici, pas apres qu'on a deplace la base vivante.
|
||||||
|
slapadd -u -q -b "${base}" -l "${ldif}" || mourir "slapadd a blanc refuse ${INSTANTANE}"
|
||||||
|
if (( essai )); then echo "ESSAI OK : ${n} entree(s) rejouables depuis ${INSTANTANE}."; return 0; fi
|
||||||
|
dir=$(slapcat -n 0 -a "(olcSuffix=${base})" | awk '/^olcDbDirectory:/{print $2}')
|
||||||
|
[[ -d "${dir}" ]] || mourir "repertoire de la base introuvable pour ${base}"
|
||||||
|
systemctl stop slapd
|
||||||
|
mettre_de_cote "${dir}"
|
||||||
|
find "${dir}" -mindepth 1 -delete
|
||||||
|
if ! slapadd -q -b "${base}" -l "${ldif}"; then
|
||||||
|
dire "slapadd a echoue : remise de la base d'avant"
|
||||||
|
find "${dir}" -mindepth 1 -delete
|
||||||
|
cp -a "${MIS_DE_COTE}/." "${dir}/"
|
||||||
|
chown -R openldap:openldap "${dir}"
|
||||||
|
systemctl start slapd
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
chown -R openldap:openldap "${dir}"
|
||||||
|
systemctl start slapd
|
||||||
|
echo "RESTAURE ${INSTANTANE} : annuaire ${base}, ${n} entree(s)."
|
||||||
|
}
|
||||||
|
|
||||||
|
tables() { # nombre de tables hors schemas systeme ; -1 si la base n'existe pas
|
||||||
|
runuser -u postgres -- psql -tAX -d "$1" -c \
|
||||||
|
"select count(*) from information_schema.tables where table_schema not in ('pg_catalog','information_schema')" \
|
||||||
|
2>/dev/null || echo -1
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_base() {
|
||||||
|
local remplacer=0 vers="" nom="" cible dump section creation proprio n
|
||||||
|
while (( $# )); do
|
||||||
|
case "$1" in
|
||||||
|
--remplacer) remplacer=1; shift ;;
|
||||||
|
--vers) vers="$2"; shift 2 ;;
|
||||||
|
--instantane) INSTANTANE="$2"; shift 2 ;;
|
||||||
|
*) nom="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[[ -n "${nom}" ]] || mourir "base : NOM requis"
|
||||||
|
cible="${vers:-${nom}}"
|
||||||
|
resoudre_instantane
|
||||||
|
if [[ -z "${INSTANTANE}" ]]; then echo "RIEN : aucune incarnation precedente."; return 0; fi
|
||||||
|
extraire "$(dirname "${DUMP_PG}")"
|
||||||
|
dump="${TMP}${DUMP_PG}"
|
||||||
|
[[ -s "${dump}" ]] || mourir "${INSTANTANE} n'emporte pas de dump PostgreSQL"
|
||||||
|
creation=$(grep -m1 -E "^CREATE DATABASE ${nom} " "${dump}" || true)
|
||||||
|
if [[ -z "${creation}" ]]; then echo "ABSENTE de ${INSTANTANE} : base ${nom}"; return 0; fi
|
||||||
|
proprio=$(grep -m1 -oP "^ALTER DATABASE ${nom} OWNER TO \K[^;]+" "${dump}" || echo postgres)
|
||||||
|
# LE PIEGE DE pg_dumpall (runbook §5) : un `CREATE DATABASE <suivante>` et, avec
|
||||||
|
# `--clean`, un `DROP DATABASE postgres;` precedent le `\connect` suivant. On coupe sur
|
||||||
|
# les trois, puis on REFUSE toute section qui viserait encore une autre base.
|
||||||
|
section="${TMP}/section.sql"
|
||||||
|
awk -v db="${nom}" '$0 == "\\connect " db {f=1; next}
|
||||||
|
f && (/^\\connect / || /^CREATE DATABASE / || /^DROP DATABASE /) {exit} f' "${dump}" > "${section}"
|
||||||
|
if grep -qE '^(DROP|CREATE|ALTER) DATABASE|^\\connect' "${section}"; then
|
||||||
|
mourir "section ${nom} hors perimetre : refus"
|
||||||
|
fi
|
||||||
|
if [[ -n "${vers}" ]]; then sed -i -E "/ ON DATABASE ${nom} /d" "${section}"; fi
|
||||||
|
n=$(tables "${cible}")
|
||||||
|
if (( n > 0 )) && (( ! remplacer )); then
|
||||||
|
mourir "la base ${cible} contient ${n} table(s) : --remplacer pour l'ecraser (elle sera mise de cote)"
|
||||||
|
fi
|
||||||
|
if (( n >= 0 )); then
|
||||||
|
mkdir -p "${MISE_DE_COTE}"
|
||||||
|
runuser -u postgres -- pg_dump -Fc "${cible}" > "${MISE_DE_COTE}/$(date +%Y%m%d-%H%M%S)-${cible}.dump"
|
||||||
|
runuser -u postgres -- dropdb --force "${cible}"
|
||||||
|
fi
|
||||||
|
runuser -u postgres -- psql -X -q -v ON_ERROR_STOP=1 -d postgres \
|
||||||
|
-c "${creation/CREATE DATABASE ${nom} /CREATE DATABASE ${cible} }" \
|
||||||
|
-c "ALTER DATABASE ${cible} OWNER TO ${proprio};"
|
||||||
|
# UNE SEULE TRANSACTION : une erreur au milieu laisse une base VIDE, que la prochaine
|
||||||
|
# passe retentera — pas une base a moitie chargee qu'on prendrait pour la bonne.
|
||||||
|
# Par l'ENTREE STANDARD, pas `-f` : psql tourne en `postgres`, qui ne peut pas lire le
|
||||||
|
# repertoire jetable de root (0700). Trouve en repetition sur data-sql-01, 2026-09-30.
|
||||||
|
runuser -u postgres -- psql -X -q --single-transaction -v ON_ERROR_STOP=1 \
|
||||||
|
-d "${cible}" < "${section}" >/dev/null
|
||||||
|
echo "RESTAURE ${INSTANTANE} : base ${nom} -> ${cible}, $(tables "${cible}") table(s)."
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_acter() {
|
||||||
|
local jeu="${1:?jeu}" etat="${2:?etat}" id="${3:-}"
|
||||||
|
install -d -m 0700 "${MARQUES}"
|
||||||
|
printf 'etat=%s\ninstantane=%s\ndate=%s\n' "${etat}" "${id}" "$(date -Is)" > "${MARQUES}/${jeu}"
|
||||||
|
echo "ACTE ${jeu} : ${etat}${id:+ (${id})}"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_choisir() {
|
||||||
|
[[ "${1:-}" == "--instantane" ]] && INSTANTANE="${2:-}"
|
||||||
|
local c
|
||||||
|
c=$(candidat "${INSTANTANE}") || exit 2
|
||||||
|
python3 -c 'import json,sys; print(json.dumps({"instantane": sys.argv[1], "heure": sys.argv[2], "naissance": int(sys.argv[3])}))' \
|
||||||
|
"${c%% *}" "$( [[ -n "${c}" ]] && printf '%s' "${c#* }" )" "$(naissance)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# LE CANDIDAT EST CALCULE MAINTENANT (2026-10-07). Il dit ce qu'une restauration lancee a
|
||||||
|
# cette minute choisirait, pas ce qui a ete restaure : au bilan de M4, il nommait
|
||||||
|
# l'instantane de la veille, et l'on a cru un moment que la journee etait perdue. Ce qui a
|
||||||
|
# ete restaure est dans le marqueur de chaque jeu ; on dit desormais s'il est encore au depot.
|
||||||
|
cmd_etat() {
|
||||||
|
local c a ids id present
|
||||||
|
echo "hote : ${MOI}"
|
||||||
|
echo "naissance : $(date -d "@$(naissance)" -Is)"
|
||||||
|
c=$(candidat "") || exit 2
|
||||||
|
# (Pas d'apostrophe dans `${c:-...}` : entre guillemets doubles, bash l'y lit comme un
|
||||||
|
# guillemet ouvrant, et tout le script devient illisible.)
|
||||||
|
if [[ -z "${c}" ]]; then c="aucun (premiere vie, ou rien avant la naissance)"; fi
|
||||||
|
echo "candidat : ${c} (ce que choisirait une restauration lancee maintenant)"
|
||||||
|
a=$(avant_raser) || exit 2
|
||||||
|
echo "avant-raser: ${a:-aucun}"
|
||||||
|
ids=$(restic snapshots --host "${MOI}" --json 2>/dev/null \
|
||||||
|
| python3 -c 'import json, sys; print(" ".join(x["short_id"] for x in json.load(sys.stdin) or []))' \
|
||||||
|
2>/dev/null || true)
|
||||||
|
for j in "${JEUX[@]}"; do
|
||||||
|
if [[ -f "${MARQUES}/${j}" ]]; then
|
||||||
|
id=$(sed -n 's/^instantane=//p' "${MARQUES}/${j}")
|
||||||
|
present=""
|
||||||
|
if [[ -n "${id}" ]]; then
|
||||||
|
if [[ " ${ids} " == *" ${id} "* ]]; then present="(au depot)"; else present="(RETIRE du depot)"; fi
|
||||||
|
fi
|
||||||
|
echo "jeu ${j} : $(tr '\n' ' ' < "${MARQUES}/${j}")${present}"
|
||||||
|
else
|
||||||
|
echo "jeu ${j} : EN ATTENTE (aucun marqueur)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# LES TEMOINS (2026-10-07). L'etat vivant est-il celui d'avant le rasage ? On extrait
|
||||||
|
# l'instantane d'avant (etiquete, sinon le candidat ou celui qu'on impose), on lit
|
||||||
|
# l'annuaire vivant, et `setops-temoins` compare. Rien n'est ecrit hors du repertoire jetable.
|
||||||
|
cmd_temoins() {
|
||||||
|
local source="avant-raser" c heure base chemins=() args=()
|
||||||
|
while (( $# )); do
|
||||||
|
case "$1" in
|
||||||
|
--candidat) source="candidat"; shift ;;
|
||||||
|
--instantane) INSTANTANE="$2"; source="impose"; shift 2 ;;
|
||||||
|
*) mourir "temoins : option inconnue : $1" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if [[ -z "${JEUX[*]:-}" ]]; then echo "SANS OBJET : ${MOI} ne detient aucun etat."; exit 4; fi
|
||||||
|
case "${source}" in
|
||||||
|
avant-raser) c=$(avant_raser) || exit 2 ;;
|
||||||
|
candidat) c=$(candidat "") || exit 2 ;;
|
||||||
|
impose) c=$(candidat "${INSTANTANE}") || exit 2 ;;
|
||||||
|
esac
|
||||||
|
if [[ -z "${c}" ]]; then echo "SANS OBJET : aucun instantane ${source} pour ${MOI}."; exit 4; fi
|
||||||
|
INSTANTANE="${c%% *}"
|
||||||
|
heure="${c#* }"
|
||||||
|
for j in "${JEUX[@]}"; do
|
||||||
|
IFS=: read -r -a morceaux <<< "${CHEMINS_JEU[${j}]}"
|
||||||
|
chemins+=("${morceaux[@]}")
|
||||||
|
args+=(--jeu "${j}=${CHEMINS_JEU[${j}]}")
|
||||||
|
if [[ -n "${VIVANT_JEU[${j}]:-}" ]]; then args+=(--vivant "${j}=${VIVANT_JEU[${j}]}"); fi
|
||||||
|
done
|
||||||
|
extraire "${chemins[@]}"
|
||||||
|
if [[ -s "${TMP}${LDIF}" ]]; then
|
||||||
|
base=$(sed -n 's/^dn: //p' "${TMP}${LDIF}" | head -n 1)
|
||||||
|
slapcat -b "${base}" > "${TMP}/vivant-annuaire.ldif" || mourir "slapcat ${base} a echoue"
|
||||||
|
args+=(--ldif "${LDIF}" --ldif-vivant "${TMP}/vivant-annuaire.ldif")
|
||||||
|
fi
|
||||||
|
set +e
|
||||||
|
/usr/local/sbin/setops-temoins --avant "${TMP}" --instantane "${INSTANTANE}" \
|
||||||
|
--heure "${heure}" --hote "${MOI}" --dump "${DUMP_PG}" "${args[@]}"
|
||||||
|
local rc=$?
|
||||||
|
set -e
|
||||||
|
exit "${rc}"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_garde() {
|
||||||
|
local attente=() c
|
||||||
|
for j in "${JEUX[@]}"; do
|
||||||
|
[[ -f "${MARQUES}/${j}" ]] || attente+=("${j}")
|
||||||
|
done
|
||||||
|
if [[ -z "${attente[*]:-}" ]]; then return 0; fi
|
||||||
|
c=$(candidat "") || exit 2
|
||||||
|
if [[ -z "${c}" ]]; then
|
||||||
|
for j in "${attente[@]}"; do cmd_acter "${j}" neuf >/dev/null; done
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
dire "REFUS : l'etat d'une incarnation precedente (${c}) attend dans le depot, non remis"
|
||||||
|
dire "pour : ${attente[*]}. Deposer maintenant pousserait l'instantane d'avant hors de"
|
||||||
|
dire "la retention. Le remettre (redeployer le role proprietaire), ou l'ecarter :"
|
||||||
|
dire " make restauration-renoncer HOTE=${MOI} JEU=<jeu> CONFIRMER=true"
|
||||||
|
exit 3
|
||||||
|
}
|
||||||
|
|
||||||
|
sous="${1:-etat}"; shift || true
|
||||||
|
case "${sous}" in
|
||||||
|
etat) cmd_etat ;;
|
||||||
|
choisir) cmd_choisir "$@" ;;
|
||||||
|
fichiers) cmd_fichiers "$@" ;;
|
||||||
|
annuaire) cmd_annuaire "$@" ;;
|
||||||
|
base) cmd_base "$@" ;;
|
||||||
|
acter) cmd_acter "$@" ;;
|
||||||
|
garde) cmd_garde ;;
|
||||||
|
temoins) cmd_temoins "$@" ;;
|
||||||
|
*) mourir "sous-commande inconnue : ${sous}" ;;
|
||||||
|
esac
|
||||||
|
|
@ -4,6 +4,31 @@
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
export RESTIC_REPOSITORY="{{ client_backup_repo }}"
|
export RESTIC_REPOSITORY="{{ client_backup_repo }}"
|
||||||
export RESTIC_PASSWORD_FILE="/etc/setops/restic.pass"
|
export RESTIC_PASSWORD_FILE="/etc/setops/restic.pass"
|
||||||
|
# UN VERROU N'EST PAS UNE PANNE (2026-10-04). La sauvegarde, la verification du depot, le
|
||||||
|
# controle de restauration et `setops-restaurer` partagent ce depot ; leurs minuteurs peuvent
|
||||||
|
# partir ensemble (`Persistent=true` rattrape au demarrage tout ce qui a ete manque). Sans
|
||||||
|
# attente, restic refuse aussitot un depot verrouille, et une sonde concluait « aucun
|
||||||
|
# instantane » ou « depot corrompu ». On attend le verrou au lieu de conclure.
|
||||||
|
restic() { command restic --retry-lock {{ client_backup_attente_verrou }} "$@"; }
|
||||||
|
|
||||||
|
# L'INSTANTANE D'AVANT RASAGE (2026-10-07). `--avant-raser` (unite
|
||||||
|
# `setops-sauvegarde-avant-raser`, lancee par `make sauvegarder-maintenant`) etiquette le
|
||||||
|
# depot « {{ client_backup_etiquette_avant_raser }} », et la retention garde cette etiquette.
|
||||||
|
# Sans elle, le premier depot de la machine reconstruite, le meme jour, chassait l'instantane
|
||||||
|
# d'avant par `--keep-daily` : apres la reconstruction de Technolibre (M4), il ne restait
|
||||||
|
# rien a quoi comparer l'etat remis. Un seul porte l'etiquette, le dernier : il est garde
|
||||||
|
# jusqu'a la reconstruction suivante, qui la lui retire.
|
||||||
|
AVANT_RASER=0
|
||||||
|
if [[ "${1:-}" == "--avant-raser" ]]; then AVANT_RASER=1; fi
|
||||||
|
ETIQUETTES=(--tag setops)
|
||||||
|
if (( AVANT_RASER )); then ETIQUETTES+=(--tag "{{ client_backup_etiquette_avant_raser }}"); fi
|
||||||
|
|
||||||
|
# 0. UN ETAT D'AVANT ATTEND-IL ? (2026-09-30)
|
||||||
|
# Sur une machine reconstruite, tant que l'etat de l'incarnation precedente n'a ete ni
|
||||||
|
# remis ni ecarte, deposer serait NUISIBLE : `restic forget --keep-daily` garde un
|
||||||
|
# instantane par jour, et celui de l'etat neuf chasserait celui d'avant, s'ils tombent le
|
||||||
|
# meme jour. On refuse donc, bruyamment (code 3). Voir `setops-restaurer garde`.
|
||||||
|
/usr/local/sbin/setops-restaurer garde
|
||||||
|
|
||||||
# 1. Dumps applicatifs (pg_dump, slapcat, forgejo dump...) vers le staging.
|
# 1. Dumps applicatifs (pg_dump, slapcat, forgejo dump...) vers le staging.
|
||||||
{% for job in client_backup_jobs %}
|
{% for job in client_backup_jobs %}
|
||||||
|
|
@ -47,7 +72,24 @@ if (( N_PRESENTS == 0 )); then
|
||||||
echo "aucun chemin declare n'existe encore sur cet hote — rien a sauvegarder." >&2
|
echo "aucun chemin declare n'existe encore sur cet hote — rien a sauvegarder." >&2
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
restic backup --host "{{ inventory_hostname }}" --tag setops "${PRESENTS[@]}"
|
restic backup --host "{{ inventory_hostname }}" "${ETIQUETTES[@]}" "${PRESENTS[@]}"
|
||||||
|
|
||||||
|
# 3 bis. L'etiquette passe au dernier depot d'avant rasage : les precedents rentrent dans
|
||||||
|
# la retention ordinaire. APRES le depot, jamais avant : s'il echoue, l'ancien reste garde.
|
||||||
|
if (( AVANT_RASER )); then
|
||||||
|
ANCIENS=$(restic snapshots --host "{{ inventory_hostname }}" --tag "{{ client_backup_etiquette_avant_raser }}" --json \
|
||||||
|
| python3 -c 'import json, sys
|
||||||
|
import datetime, re
|
||||||
|
def t(x):
|
||||||
|
s = re.sub(r"(\.\d{6})\d+", r"\1", x["time"])
|
||||||
|
return datetime.datetime.fromisoformat(re.sub(r"Z$", "+00:00", s)).timestamp()
|
||||||
|
s = sorted(json.load(sys.stdin) or [], key=t)
|
||||||
|
print(" ".join(x["id"] for x in s[:-1]))')
|
||||||
|
if [[ -n "${ANCIENS}" ]]; then
|
||||||
|
read -r -a ANCIENS_IDS <<< "${ANCIENS}"
|
||||||
|
restic tag --remove "{{ client_backup_etiquette_avant_raser }}" "${ANCIENS_IDS[@]}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# 4. Rétention.
|
# 4. Rétention.
|
||||||
restic forget {{ client_backup_retention }} --prune
|
restic forget {{ client_backup_retention }} --keep-tag "{{ client_backup_etiquette_avant_raser }}" --prune
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
# Géré par Set-OPS (rôle client_backup). Ne pas éditer à la main.
|
||||||
|
# Le depot d'avant rasage : etiquete, garde jusqu'a la reconstruction suivante.
|
||||||
|
# Lance par `make sauvegarder-maintenant` (playbooks/backup/restauration.yml), jamais par un minuteur.
|
||||||
|
[Unit]
|
||||||
|
Description=Sauvegarde Set-OPS d'avant rasage (restic hors-nœud, etiquetee)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/sbin/setops-sauvegarder.sh --avant-raser
|
||||||
|
Nice=10
|
||||||
|
IOSchedulingClass=idle
|
||||||
|
|
@ -14,6 +14,12 @@
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
export RESTIC_PASSWORD_FILE="/etc/setops/restic.pass"
|
export RESTIC_PASSWORD_FILE="/etc/setops/restic.pass"
|
||||||
|
# UN VERROU N'EST PAS UNE PANNE (2026-10-04). La sauvegarde, la verification du depot, le
|
||||||
|
# controle de restauration et `setops-restaurer` partagent ce depot ; leurs minuteurs peuvent
|
||||||
|
# partir ensemble (`Persistent=true` rattrape au demarrage tout ce qui a ete manque). Sans
|
||||||
|
# attente, restic refuse aussitot un depot verrouille, et une sonde concluait « aucun
|
||||||
|
# instantane » ou « depot corrompu ». On attend le verrou au lieu de conclure.
|
||||||
|
restic() { command restic --retry-lock {{ client_backup_attente_verrou }} "$@"; }
|
||||||
export RESTIC_REPOSITORY="{{ client_backup_repo }}"
|
export RESTIC_REPOSITORY="{{ client_backup_repo }}"
|
||||||
API="https://{{ client_backup_icinga_hote }}.{{ domaine_interne }}:5665"
|
API="https://{{ client_backup_icinga_hote }}.{{ domaine_interne }}:5665"
|
||||||
TTL={{ client_backup_ttl_icinga }}
|
TTL={{ client_backup_ttl_icinga }}
|
||||||
|
|
@ -22,6 +28,16 @@ AGE_CRIT={{ client_backup_age_crit_h }}
|
||||||
MOI="{{ inventory_hostname }}"
|
MOI="{{ inventory_hostname }}"
|
||||||
MOTDEPASSE="$(cat /etc/setops/icinga-api.pass)"
|
MOTDEPASSE="$(cat /etc/setops/icinga-api.pass)"
|
||||||
|
|
||||||
|
# Les chemins que `setops-sauvegarde` emporte : de quoi dire, devant un instantane vide,
|
||||||
|
# s'il n'y avait RIEN a emporter ou si la sauvegarde a manque des donnees bien presentes.
|
||||||
|
CHEMINS=(
|
||||||
|
{% for job in client_backup_jobs %}
|
||||||
|
{% for c in job.chemins %}
|
||||||
|
"{{ c }}"
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
)
|
||||||
|
|
||||||
rapporter() { # $1=code $2=texte
|
rapporter() { # $1=code $2=texte
|
||||||
local charge reponse
|
local charge reponse
|
||||||
charge=$(python3 -c 'import json,sys; print(json.dumps({
|
charge=$(python3 -c 'import json,sys; print(json.dumps({
|
||||||
|
|
@ -68,10 +84,27 @@ for l in sys.stdin:
|
||||||
print(n)' 2>/dev/null || echo 0)
|
print(n)' 2>/dev/null || echo 0)
|
||||||
|
|
||||||
if (( fichiers == 0 )); then
|
if (( fichiers == 0 )); then
|
||||||
# AVERTISSEMENT et non CRITIQUE : la machine ne peut pas distinguer « les donnees ont
|
# INSTANTANE VIDE : on regarde ce que les chemins sauvegardes contiennent ICI, sur le
|
||||||
# disparu » de « il n'y en a pas encore » (un /srv/web sans webapp). C'est a un humain
|
# noeud. Trois cas, trois messages (2026-10-04) :
|
||||||
# de trancher — mais il doit le VOIR.
|
# - rien sur le noeud : rien a sauvegarder, l'instantane vide est juste. Avertissement
|
||||||
rapporter 1 "N'EMPORTE RIEN : instantane sans aucun fichier (${taille} octets). Legitime si ce noeud n'a pas encore de donnees — a confirmer."
|
# quand meme : un humain doit savoir que ce noeud n'est protege de rien ;
|
||||||
|
# - des fichiers posterieurs a l'instantane : ils sont arrives depuis, la prochaine
|
||||||
|
# sauvegarde les emportera. Avertissement ;
|
||||||
|
# - des fichiers deja la AU MOMENT de l'instantane : la sauvegarde les a manques.
|
||||||
|
# CRITIQUE — c'est une vraie panne.
|
||||||
|
sur_place=0; anciens=0
|
||||||
|
for c in "${CHEMINS[@]}"; do
|
||||||
|
[[ -e "$c" ]] || continue
|
||||||
|
sur_place=$(( sur_place + $(find "$c" -type f 2>/dev/null | wc -l) ))
|
||||||
|
anciens=$(( anciens + $(find "$c" -type f ! -newermt "@${secondes}" 2>/dev/null | wc -l) ))
|
||||||
|
done
|
||||||
|
if (( sur_place == 0 )); then
|
||||||
|
rapporter 1 "RIEN A SAUVEGARDER : ${CHEMINS[*]} ne contient aucun fichier sur ce noeud ; instantane vide, comme attendu."
|
||||||
|
elif (( anciens == 0 )); then
|
||||||
|
rapporter 1 "DONNEES ARRIVEES DEPUIS : ${sur_place} fichier(s) dans ${CHEMINS[*]}, tous posterieurs au dernier instantane (il y a ${age_h} h, vide). La prochaine sauvegarde les emportera."
|
||||||
|
else
|
||||||
|
rapporter 2 "N'EMPORTE RIEN : instantane vide alors que ${CHEMINS[*]} contenait deja ${anciens} fichier(s) a sa prise. La sauvegarde manque ses donnees."
|
||||||
|
fi
|
||||||
elif (( age_h >= AGE_CRIT )); then
|
elif (( age_h >= AGE_CRIT )); then
|
||||||
rapporter 2 "PERIME : dernier instantane il y a ${age_h} h (seuil ${AGE_CRIT} h), ${fichiers} fichier(s)."
|
rapporter 2 "PERIME : dernier instantane il y a ${age_h} h (seuil ${AGE_CRIT} h), ${fichiers} fichier(s)."
|
||||||
elif (( age_h >= AGE_WARN )); then
|
elif (( age_h >= AGE_WARN )); then
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,12 @@
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
export RESTIC_PASSWORD_FILE="/etc/setops/restic.pass"
|
export RESTIC_PASSWORD_FILE="/etc/setops/restic.pass"
|
||||||
|
# UN VERROU N'EST PAS UNE PANNE (2026-10-04). La sauvegarde, la verification du depot, le
|
||||||
|
# controle de restauration et `setops-restaurer` partagent ce depot ; leurs minuteurs peuvent
|
||||||
|
# partir ensemble (`Persistent=true` rattrape au demarrage tout ce qui a ete manque). Sans
|
||||||
|
# attente, restic refuse aussitot un depot verrouille, et une sonde concluait « aucun
|
||||||
|
# instantane » ou « depot corrompu ». On attend le verrou au lieu de conclure.
|
||||||
|
restic() { command restic --retry-lock {{ client_backup_attente_verrou }} "$@"; }
|
||||||
export RESTIC_REPOSITORY="{{ client_backup_repo }}"
|
export RESTIC_REPOSITORY="{{ client_backup_repo }}"
|
||||||
API="https://{{ client_backup_icinga_hote }}.{{ domaine_interne }}:5665"
|
API="https://{{ client_backup_icinga_hote }}.{{ domaine_interne }}:5665"
|
||||||
TTL={{ client_backup_ttl_restauration }}
|
TTL={{ client_backup_ttl_restauration }}
|
||||||
|
|
@ -26,6 +32,15 @@ PART="{{ client_backup_restauration_part }}"
|
||||||
MOI="{{ inventory_hostname }}"
|
MOI="{{ inventory_hostname }}"
|
||||||
MOTDEPASSE="$(cat /etc/setops/icinga-api.pass)"
|
MOTDEPASSE="$(cat /etc/setops/icinga-api.pass)"
|
||||||
|
|
||||||
|
# Les chemins que `setops-sauvegarde` emporte (voir `setops-verifier-mon-depot.sh`).
|
||||||
|
CHEMINS=(
|
||||||
|
{% for job in client_backup_jobs %}
|
||||||
|
{% for c in job.chemins %}
|
||||||
|
"{{ c }}"
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
)
|
||||||
|
|
||||||
rapporter() { # $1=code $2=texte
|
rapporter() { # $1=code $2=texte
|
||||||
local charge reponse
|
local charge reponse
|
||||||
charge=$(python3 -c 'import json,sys; print(json.dumps({
|
charge=$(python3 -c 'import json,sys; print(json.dumps({
|
||||||
|
|
@ -76,7 +91,17 @@ octets=$(du -sb "${CIBLE}" | cut -f1)
|
||||||
if (( obtenus != attendus )); then
|
if (( obtenus != attendus )); then
|
||||||
rapporter 2 "RESTAURATION INCOMPLETE : ${obtenus} fichier(s) restaure(s) sur ${attendus} dans l'instantane."
|
rapporter 2 "RESTAURATION INCOMPLETE : ${obtenus} fichier(s) restaure(s) sur ${attendus} dans l'instantane."
|
||||||
elif (( attendus == 0 )); then
|
elif (( attendus == 0 )); then
|
||||||
rapporter 1 "Instantane restaurable mais VIDE : rien a restaurer. Legitime si ce noeud n'a pas encore de donnees."
|
# Avertissement dans les deux cas ; le message dit lequel. Le jugement d'une sauvegarde
|
||||||
|
# qui manque ses donnees revient a la sonde `sauvegarde`, qui sait dater l'instantane.
|
||||||
|
sur_place=0
|
||||||
|
for c in "${CHEMINS[@]}"; do
|
||||||
|
[[ -e "$c" ]] && sur_place=$(( sur_place + $(find "$c" -type f 2>/dev/null | wc -l) ))
|
||||||
|
done
|
||||||
|
if (( sur_place == 0 )); then
|
||||||
|
rapporter 1 "RIEN A RESTAURER : instantane vide, et ${CHEMINS[*]} ne contient aucun fichier sur ce noeud — comme attendu."
|
||||||
|
else
|
||||||
|
rapporter 1 "Instantane restaurable mais VIDE, alors que ${CHEMINS[*]} contient ${sur_place} fichier(s) sur ce noeud : voir la sonde sauvegarde."
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
rapporter 0 "Restauration verifiee : ${obtenus} fichier(s), ${octets} octets, contenu recompare ; integrite du depot relue sur ${PART}."
|
rapporter 0 "Restauration verifiee : ${obtenus} fichier(s), ${octets} octets, contenu recompare ; integrite du depot relue sur ${PART}."
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -19,5 +19,7 @@ client_backup_groupes_etat:
|
||||||
- serveur_forgejo
|
- serveur_forgejo
|
||||||
- serveur_nextcloud
|
- serveur_nextcloud
|
||||||
- serveur_rspamd
|
- serveur_rspamd
|
||||||
- serveur_web_frontal
|
|
||||||
- serveur_web_dorsal
|
- serveur_web_dorsal
|
||||||
|
- serveur_icinga
|
||||||
|
- serveur_prometheus
|
||||||
|
- serveur_loki
|
||||||
|
|
|
||||||
|
|
@ -88,3 +88,11 @@ client_journal_syslog_emetteur: "frontiere"
|
||||||
# une liste de plus qui suit une autre — et qui prendrait du retard.
|
# une liste de plus qui suit une autre — et qui prendrait du retard.
|
||||||
client_journal_audit_actif: "{{ 'serveur_durci' in group_names }}"
|
client_journal_audit_actif: "{{ 'serveur_durci' in group_names }}"
|
||||||
client_journal_audit_chemin: "/var/log/audit/audit.log"
|
client_journal_audit_chemin: "/var/log/audit/audit.log"
|
||||||
|
|
||||||
|
# LES MACHINES QUI SONDENT — celles qui portent `client_sante`, donc qui jouent la sonde
|
||||||
|
# « connectivite » chaque minute. Leurs connexions de sonde ne sont pas expediees (voir le
|
||||||
|
# gabarit). Derive de l'inventaire : une machine ajoutee y entre sans qu'on l'ecrive.
|
||||||
|
# Vide = aucun filtre.
|
||||||
|
client_journal_sondeurs: >-
|
||||||
|
{{ groups.get('client_sante', []) | map('extract', hostvars)
|
||||||
|
| map(attribute='ansible_host', default='') | select('match', '^[0-9.]+$') | list }}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
# precedent : ce qui AUGMENTE est une perte en cours, ce qui stagne est une cicatrice.
|
# precedent : ce qui AUGMENTE est une perte en cours, ce qui stagne est une cicatrice.
|
||||||
sondes:
|
sondes:
|
||||||
- nom: journaux
|
- nom: journaux
|
||||||
|
metier: socle
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
raison: >-
|
raison: >-
|
||||||
Les journaux de cette machine partent-ils vraiment vers Loki ? Alloy peut tourner,
|
Les journaux de cette machine partent-ils vraiment vers Loki ? Alloy peut tourner,
|
||||||
|
|
|
||||||
|
|
@ -225,11 +225,16 @@
|
||||||
# perdre ferait passer une cicatrice ancienne pour une perte en cours.
|
# perdre ferait passer une cicatrice ancienne pour une perte en cours.
|
||||||
- name: Assurer le repertoire d etat des sondes
|
- name: Assurer le repertoire d etat des sondes
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
|
# 0755, COMME PARTOUT AILLEURS (2026-10-01) : quatre roles tenaient ce repertoire, trois
|
||||||
|
# en 0750 et `serveur_artefacts` en 0755 — apt-cacher-ng doit le traverser pour servir
|
||||||
|
# le depot de binaires du site. Le dernier deploye gagnait : au site, le 2026-10-01, il
|
||||||
|
# rendait 403, et chaque runner allait chercher 500 Mo sur Internet. Le repertoire ne
|
||||||
|
# tient que des etats de sondes, deja lisibles. Garde : test_repertoires_partages.py.
|
||||||
path: /var/lib/setops
|
path: /var/lib/setops
|
||||||
state: directory
|
state: directory
|
||||||
owner: root
|
owner: root
|
||||||
group: root
|
group: root
|
||||||
mode: "0750"
|
mode: "0755"
|
||||||
|
|
||||||
- name: Deposer la sonde d expedition des journaux
|
- name: Deposer la sonde d expedition des journaux
|
||||||
ansible.builtin.template:
|
ansible.builtin.template:
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,85 @@ loki.write "loki" {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{% set sondeurs = client_journal_sondeurs | map('regex_escape') | join('|') | replace('\\', '\\\\') %}
|
||||||
|
{% if sondeurs %}
|
||||||
|
// --- LE BRUIT DE LA SONDE « connectivite » RESTE SUR LA MACHINE (2026-10-03) ---
|
||||||
|
//
|
||||||
|
// Chaque minute, chaque machine ouvre une connexion TCP vers les ports que le registre des
|
||||||
|
// flux lui promet, puis la referme sans rien dire (`nftables_baseline`,
|
||||||
|
// `sonde-connectivite.sh`). Ceux qui ecoutent le notent : `sshd` (« Connection closed by
|
||||||
|
// <pair> port <n> ») et les serveurs Go — Forgejo, step-ca — (« TLS handshake error from
|
||||||
|
// <pair>:<n>: EOF »). Mesure au site : le journal de chaque VM est passe de 2 000 a
|
||||||
|
// 14 000 lignes par jour le 2026-10-01, et ces deux phrases faisaient 98 % des « erreurs »
|
||||||
|
// de la forge et de l'autorite. Rien a corriger a la source : `sshd` journalise toute
|
||||||
|
// connexion fermee avant l'echange de cles, quel que soit le client.
|
||||||
|
//
|
||||||
|
// CE QUI EST JETE EST ETROIT : la phrase EXACTE, et seulement depuis une machine qui porte
|
||||||
|
// la sonde (`client_sante`, derive de l'inventaire). Le meme message venu d'ailleurs — un
|
||||||
|
// balayage, une machine inconnue — passe. Les lignes restent dans le journal local ; le
|
||||||
|
// compte de ce qui est jete est `loki_process_dropped_lines_total{reason="sonde_connectivite"}`,
|
||||||
|
// distinct des pertes que surveille la sonde « journaux ».
|
||||||
|
loki.process "sondes" {
|
||||||
|
forward_to = [loki.write.loki.receiver]
|
||||||
|
|
||||||
|
stage.drop {
|
||||||
|
expression = "^Connection closed by ({{ sondeurs }}) port [0-9]+$"
|
||||||
|
drop_counter_reason = "sonde_connectivite"
|
||||||
|
}
|
||||||
|
|
||||||
|
stage.drop {
|
||||||
|
expression = "TLS handshake error from ({{ sondeurs }}):[0-9]+: EOF$"
|
||||||
|
drop_counter_reason = "sonde_connectivite"
|
||||||
|
}
|
||||||
|
|
||||||
|
// LE COURRIEL PARLE PLUS QUE LES AUTRES (mesure chez les deux locataires, 2026-10-03).
|
||||||
|
// Postfix ecrit trois lignes par connexion de sonde — quatre sur le port chiffre — et
|
||||||
|
// faisait de `edge-mta-01` la machine la plus bavarde de chaque locataire (7 000 lignes
|
||||||
|
// par heure). Une VRAIE session venue de la flotte perd sa ligne `connect from`, rien
|
||||||
|
// d'autre : ses identifiants de file (`client=`), et son `disconnect` qui compte des
|
||||||
|
// commandes (`commands=N/M`, jamais `0/0`), restent.
|
||||||
|
stage.drop {
|
||||||
|
expression = "^connect from [^\\[ ]+\\[({{ sondeurs }})\\]$"
|
||||||
|
drop_counter_reason = "sonde_connectivite"
|
||||||
|
}
|
||||||
|
|
||||||
|
stage.drop {
|
||||||
|
expression = "^(NOQUEUE: )?lost connection after CONNECT from [^\\[ ]+\\[({{ sondeurs }})\\]$"
|
||||||
|
drop_counter_reason = "sonde_connectivite"
|
||||||
|
}
|
||||||
|
|
||||||
|
stage.drop {
|
||||||
|
expression = "^disconnect from [^\\[ ]+\\[({{ sondeurs }})\\] commands=0/0$"
|
||||||
|
drop_counter_reason = "sonde_connectivite"
|
||||||
|
}
|
||||||
|
|
||||||
|
stage.drop {
|
||||||
|
expression = "^SSL_accept error from [^\\[ ]+\\[({{ sondeurs }})\\]: lost connection$"
|
||||||
|
drop_counter_reason = "sonde_connectivite"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dovecot LMTP, en TLS des l'accueil : la sonde ferme avant la poignee. Seule la forme
|
||||||
|
// « fermee avant de commencer » est jetee ; toute autre raison d'echec TLS passe, et une
|
||||||
|
// livraison reelle se lit toujours a `saved mail to`.
|
||||||
|
stage.drop {
|
||||||
|
expression = "^lmtp\\([0-9]+\\): Connect from ({{ sondeurs }})$"
|
||||||
|
drop_counter_reason = "sonde_connectivite"
|
||||||
|
}
|
||||||
|
|
||||||
|
stage.drop {
|
||||||
|
expression = "^lmtp\\([0-9]+\\): Error: lmtp-server: conn ({{ sondeurs }}):[0-9]+ \\[[0-9]+\\]: SSL handshake failed: Connection closed$"
|
||||||
|
drop_counter_reason = "sonde_connectivite"
|
||||||
|
}
|
||||||
|
|
||||||
|
stage.drop {
|
||||||
|
expression = "^lmtp\\([0-9]+\\): Disconnect from ({{ sondeurs }}): SSL Initialization failed \\(state=GREETING\\)$"
|
||||||
|
drop_counter_reason = "sonde_connectivite"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
loki.source.journal "journal" {
|
loki.source.journal "journal" {
|
||||||
forward_to = [loki.write.loki.receiver]
|
forward_to = [{{ 'loki.process.sondes.receiver' if sondeurs else 'loki.write.loki.receiver' }}]
|
||||||
max_age = "12h"
|
max_age = "12h"
|
||||||
labels = {
|
labels = {
|
||||||
job = "systemd-journal",
|
job = "systemd-journal",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
# donc DECLARES sans objet, dans une liste qu'une machine de metal peut vider.
|
# donc DECLARES sans objet, dans une liste qu'une machine de metal peut vider.
|
||||||
sondes:
|
sondes:
|
||||||
- nom: metriques
|
- nom: metriques
|
||||||
|
metier: socle
|
||||||
# Six fois la periode du porteur (15 min) : cinq passages peuvent manquer avant
|
# Six fois la periode du porteur (15 min) : cinq passages peuvent manquer avant
|
||||||
# qu'Icinga ne perime. On ne crie pas pour un hoquet, on crie pour un silence.
|
# qu'Icinga ne perime. On ne crie pas pour un hoquet, on crie pour un silence.
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,16 @@ client_pki_cle: "{{ client_pki_steppath }}/certs/{{ client_pki_nom_cert }}.key"
|
||||||
# ON N'AJOUTE QUE CE QUE CET HOTE SERT REELLEMENT : `edge` nomme le groupe qui rend le
|
# ON N'AJOUTE QUE CE QUE CET HOTE SERT REELLEMENT : `edge` nomme le groupe qui rend le
|
||||||
# service, et l'hote doit en faire partie. Un certificat qui revendiquerait le nom d'un
|
# service, et l'hote doit en faire partie. Un certificat qui revendiquerait le nom d'un
|
||||||
# service rendu ailleurs serait une usurpation, pas une commodite.
|
# service rendu ailleurs serait une usurpation, pas une commodite.
|
||||||
|
#
|
||||||
|
# L'AC INTERNE NE SIGNE QUE LE DOMAINE INTERNE (2026-09-29). Le web frontal sert les noms
|
||||||
|
# PUBLICS du locataire : ils relevent de Let's Encrypt. step-ca n'a aucune politique de noms
|
||||||
|
# et les aurait signes — sans danger (personne dehors ne lui fait confiance), mais sans objet.
|
||||||
|
#
|
||||||
|
# ET LE SERVICE LUI-MEME, QUAND LES MACHINES Y VONT DIRECTEMENT (2026-09-29). Avec
|
||||||
|
# `resolution_interne: service` (le site), le plancher et la zone menent le nom au service,
|
||||||
|
# pas a l'edge : c'est lui qui doit le porter. Sans cela, une reconstruction de
|
||||||
|
# `site-forge-01` aurait rendu un certificat sans `forge.genese.internal`, et le runner du
|
||||||
|
# site — qui y va directement — aurait bute sur le nom.
|
||||||
client_pki_expositions: "{{ hosts_statiques_expositions | default([]) }}"
|
client_pki_expositions: "{{ hosts_statiques_expositions | default([]) }}"
|
||||||
client_pki_sans: >-
|
client_pki_sans: >-
|
||||||
{{ ([client_pki_nom_cert,
|
{{ ([client_pki_nom_cert,
|
||||||
|
|
@ -52,6 +62,14 @@ client_pki_sans: >-
|
||||||
| selectattr('edge', 'defined')
|
| selectattr('edge', 'defined')
|
||||||
| selectattr('fqdn', 'defined')
|
| selectattr('fqdn', 'defined')
|
||||||
| selectattr('edge', 'in', group_names)
|
| selectattr('edge', 'in', group_names)
|
||||||
|
| selectattr('domaine', 'defined')
|
||||||
|
| selectattr('domaine', 'equalto', domaine_interne | default(''))
|
||||||
|
| map(attribute='fqdn') | list)
|
||||||
|
+ (client_pki_expositions
|
||||||
|
| selectattr('fqdn', 'defined')
|
||||||
|
| selectattr('resout_vers', 'defined')
|
||||||
|
| selectattr('resout_vers', 'equalto', 'service')
|
||||||
|
| selectattr('hote', 'equalto', inventory_hostname)
|
||||||
| map(attribute='fqdn') | list))
|
| map(attribute='fqdn') | list))
|
||||||
| select | unique | list }}
|
| select | unique | list }}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
# contraire. La sonde compare donc les deux quand un service consomme le certificat.
|
# contraire. La sonde compare donc les deux quand un service consomme le certificat.
|
||||||
sondes:
|
sondes:
|
||||||
- nom: certificat
|
- nom: certificat
|
||||||
|
metier: socle
|
||||||
# 5400 s = une heure et demie, soit six fois la periode du porteur (15 min). Cinq
|
# 5400 s = une heure et demie, soit six fois la periode du porteur (15 min). Cinq
|
||||||
# passages peuvent manquer avant qu'Icinga ne perime : on ne crie pas pour un hoquet,
|
# passages peuvent manquer avant qu'Icinga ne perime : on ne crie pas pour un hoquet,
|
||||||
# on crie pour un silence.
|
# on crie pour un silence.
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,17 @@
|
||||||
# L'empreinte du root CA est la SOURCE DE VÉRITÉ de l'autorité elle-même : on la
|
# L'empreinte du root CA est la SOURCE DE VÉRITÉ de l'autorité elle-même : on la
|
||||||
# dérive à chaud (robuste au from-zero — une AC régénérée a une empreinte neuve).
|
# dérive à chaud (robuste au from-zero — une AC régénérée a une empreinte neuve).
|
||||||
# `client_pki_ca_fingerprint_override` permet d'épingler explicitement si besoin.
|
# `client_pki_ca_fingerprint_override` permet d'épingler explicitement si besoin.
|
||||||
|
#
|
||||||
|
# UNE FOIS POUR TOUTE LA FLOTTE (`run_once`, 2026-09-30). L'empreinte est la meme pour
|
||||||
|
# chaque hote : la deriver par hote lancait TREIZE modules Python en parallele sur l'AC —
|
||||||
|
# 765 Mo, 1 vCPU. A la reconstruction de Technolibre, le noyau a tue le module de
|
||||||
|
# `collab-01` (rc=137) et l'agent de journaux au passage. Avec `run_once`, le resultat
|
||||||
|
# enregistre vaut pour tous les hotes du play.
|
||||||
- name: Dériver l'empreinte du root CA depuis l'autorité
|
- name: Dériver l'empreinte du root CA depuis l'autorité
|
||||||
ansible.builtin.command:
|
ansible.builtin.command:
|
||||||
cmd: "step certificate fingerprint {{ serveur_step_ca_steppath | default('/etc/step-ca') }}/certs/root_ca.crt"
|
cmd: "step certificate fingerprint {{ serveur_step_ca_steppath | default('/etc/step-ca') }}/certs/root_ca.crt"
|
||||||
delegate_to: "{{ groups['serveur_step_ca'][0] }}"
|
delegate_to: "{{ groups['serveur_step_ca'][0] }}"
|
||||||
|
run_once: true
|
||||||
changed_when: false
|
changed_when: false
|
||||||
check_mode: false
|
check_mode: false
|
||||||
register: client_pki_fingerprint_ac
|
register: client_pki_fingerprint_ac
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,11 @@ client_sante_unites_tolerees: []
|
||||||
# Six passages du porteur.
|
# Six passages du porteur.
|
||||||
client_sante_ttl_sondes: 5400
|
client_sante_ttl_sondes: 5400
|
||||||
|
|
||||||
|
# LES SONDES A LA MINUTE (2026-09-30) — voir `setops-sante.sh.j2`, mode `minute`. Le `ttl`
|
||||||
|
# couvre trois rapports : une minute manquee ne crie pas, trois oui.
|
||||||
|
client_sante_repertoire_minute: /usr/local/lib/setops/sondes-minute
|
||||||
|
client_sante_ttl_minute: 180
|
||||||
|
|
||||||
# Delai de garde de CHAQUE sonde. Une sonde est cense repondre vite ; celle qui depasse
|
# Delai de garde de CHAQUE sonde. Une sonde est cense repondre vite ; celle qui depasse
|
||||||
# n'a pas de verdict, et on le dit (INCONNU) plutot que de figer tout le rapport.
|
# n'a pas de verdict, et on le dit (INCONNU) plutot que de figer tout le rapport.
|
||||||
client_sante_delai_sonde: 20
|
client_sante_delai_sonde: 20
|
||||||
|
|
@ -69,3 +74,15 @@ client_sante_paquets:
|
||||||
# machines ; ces seuils laissent donc voir une croissance longtemps avant la panne.
|
# machines ; ces seuils laissent donc voir une croissance longtemps avant la panne.
|
||||||
client_sante_disque_avert: 80
|
client_sante_disque_avert: 80
|
||||||
client_sante_disque_crit: 90
|
client_sante_disque_crit: 90
|
||||||
|
|
||||||
|
# Le plancher que la sonde « plancher » compare a l'autoritatif. Mise en defaut PAR
|
||||||
|
# PARAMETRE : un fichier vide ou absent — la sonde doit alors voir tous les noms manquer.
|
||||||
|
client_sante_plancher_fichier: "/etc/hosts"
|
||||||
|
|
||||||
|
# LES UNITES QUE CE ROLE POSE — la liste que `tasks/retirer.yml` defait sur un hote sorti du
|
||||||
|
# groupe. Ecrite a cote des gabarits qu'elle nomme : une unite ajoutee au role s'ajoute ici.
|
||||||
|
client_sante_unites_posees:
|
||||||
|
- setops-sante.timer
|
||||||
|
- setops-sante.service
|
||||||
|
- setops-sondes-minute.timer
|
||||||
|
- setops-sondes-minute.service
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,12 @@
|
||||||
- name: Recharger systemd
|
- name: Recharger systemd
|
||||||
ansible.builtin.systemd:
|
ansible.builtin.systemd:
|
||||||
daemon_reload: true
|
daemon_reload: true
|
||||||
|
|
||||||
|
# UNE UNITE RETIREE PEUT RESTER « failed » DANS LA MEMOIRE DE systemd, et c'est cet etat
|
||||||
|
# que lit `node_exporter`. Recharger ne l'efface pas : il faut `reset-failed`, faute de
|
||||||
|
# quoi le retrait se verrait sur disque et pas dans les metriques (`tasks/retirer.yml`).
|
||||||
|
- name: Oublier l'echec du porteur retire
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
systemctl reset-failed setops-sante.service setops-sondes-minute.service 2>/dev/null || true
|
||||||
|
systemctl daemon-reload
|
||||||
|
changed_when: true
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,20 @@
|
||||||
# script, et P64 veut, a juste titre, que la declaration suive le role qui depose.
|
# script, et P64 veut, a juste titre, que la declaration suive le role qui depose.
|
||||||
sondes:
|
sondes:
|
||||||
- nom: disque
|
- nom: disque
|
||||||
|
metier: socle
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
raison: >-
|
raison: >-
|
||||||
Reste-t-il de la place, et des inodes, sur chaque systeme de fichiers ? Seuls le
|
Reste-t-il de la place, et des inodes, sur chaque systeme de fichiers ? Seuls le
|
||||||
cache apt et les depots de sauvegarde etaient surveilles : un disque plein arretait
|
cache apt et les depots de sauvegarde etaient surveilles : un disque plein arretait
|
||||||
la base ou le courriel sans prevenir.
|
la base ou le courriel sans prevenir.
|
||||||
|
- nom: plancher
|
||||||
|
metier: socle
|
||||||
|
ttl: 5400
|
||||||
|
# Posee sous la condition du plancher (voir `setops-sondes.conf.j2`, 2026-09-28).
|
||||||
|
seulement_si: hosts_statiques_actif
|
||||||
|
defini_par: hosts_statiques
|
||||||
|
raison: >-
|
||||||
|
Le plancher /etc/hosts de la machine dit-il la meme chose que l'autoritatif ? Les deux
|
||||||
|
suivent le plan, chacun quand son role est rejoue. Le 2026-09-29, la console manquait
|
||||||
|
aux planchers des deux locataires : depuis l'edge, elle ne se resolvait pas, et le
|
||||||
|
plancher y est la seule resolution des noms internes.
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,14 @@
|
||||||
|
|
||||||
- name: Assurer le répertoire des secrets Set-OPS
|
- name: Assurer le répertoire des secrets Set-OPS
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
|
# 0700, COMME `client_backup` ET `serveur_backup` (2026-10-01) : ce repertoire tient des
|
||||||
|
# secrets, et tous ses lecteurs tournent en root. Ce role etait seul a l'ouvrir en 0755,
|
||||||
|
# et le dernier deploye gagnait. Garde : test_repertoires_partages.py.
|
||||||
path: /etc/setops
|
path: /etc/setops
|
||||||
state: directory
|
state: directory
|
||||||
owner: root
|
owner: root
|
||||||
group: root
|
group: root
|
||||||
mode: "0755"
|
mode: "0700"
|
||||||
|
|
||||||
- name: Déposer le mot de passe d'API Icinga
|
- name: Déposer le mot de passe d'API Icinga
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
|
|
@ -163,9 +166,31 @@
|
||||||
group: root
|
group: root
|
||||||
mode: "0750"
|
mode: "0750"
|
||||||
|
|
||||||
|
# LA SONDE « plancher » (2026-09-29), sous la condition meme du plancher : sans plancher
|
||||||
|
# gere (`hosts_statiques_actif: false`), il n'y a rien a comparer. Posee ici pour la raison
|
||||||
|
# de « disque » : ce role tourne sur tout hote qui rapporte, sans toucher au systeme.
|
||||||
|
- name: Deposer la sonde du plancher de resolution
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: sonde-plancher.sh.j2
|
||||||
|
dest: /usr/local/lib/setops/sondes/plancher.sh
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0750"
|
||||||
|
when: hosts_statiques_actif | default(true) | bool
|
||||||
|
|
||||||
|
- name: Assurer le repertoire des sondes a la minute
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ client_sante_repertoire_minute }}"
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
- name: Relever les sondes reellement posees
|
- name: Relever les sondes reellement posees
|
||||||
ansible.builtin.find:
|
ansible.builtin.find:
|
||||||
paths: "{{ client_sante_repertoire_sondes | default('/usr/local/lib/setops/sondes') }}"
|
paths:
|
||||||
|
- "{{ client_sante_repertoire_sondes | default('/usr/local/lib/setops/sondes') }}"
|
||||||
|
- "{{ client_sante_repertoire_minute }}"
|
||||||
patterns: "*.sh"
|
patterns: "*.sh"
|
||||||
register: client_sante_posees
|
register: client_sante_posees
|
||||||
|
|
||||||
|
|
@ -211,11 +236,33 @@
|
||||||
state: started
|
state: started
|
||||||
daemon_reload: true
|
daemon_reload: true
|
||||||
|
|
||||||
|
- name: Déployer l'unité et le minuteur des sondes a la minute
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: "{{ item }}.j2"
|
||||||
|
dest: "/etc/systemd/system/{{ item }}"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
loop:
|
||||||
|
- setops-sondes-minute.service
|
||||||
|
- setops-sondes-minute.timer
|
||||||
|
notify: Recharger systemd
|
||||||
|
|
||||||
|
- name: Activer les sondes a la minute
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: setops-sondes-minute.timer
|
||||||
|
enabled: true
|
||||||
|
state: started
|
||||||
|
daemon_reload: true
|
||||||
|
# En simulation, l'unite n'a pas ete posee : l'activer echouerait sans rien dire du reel.
|
||||||
|
when: not ansible_check_mode
|
||||||
|
|
||||||
# PREMIER RAPPORT IMMEDIAT, et ce n'est pas une commodite : sans lui, le service
|
# PREMIER RAPPORT IMMEDIAT, et ce n'est pas une commodite : sans lui, le service
|
||||||
# reste « en attente » jusqu'au premier declenchement, et un deploiement qui vient
|
# reste « en attente » jusqu'au premier declenchement, et un deploiement qui vient
|
||||||
# d'installer la supervision laisserait un tableau vide qu'on prendrait pour un
|
# d'installer la supervision laisserait un tableau vide qu'on prendrait pour un
|
||||||
# tableau sain.
|
# tableau sain.
|
||||||
- name: Rapporter tout de suite (ne pas laisser un tableau vide passer pour sain)
|
- name: Rapporter tout de suite (ne pas laisser un tableau vide passer pour sain)
|
||||||
ansible.builtin.command: /usr/local/sbin/setops-sante.sh
|
ansible.builtin.command: "/usr/local/sbin/setops-sante.sh {{ item }}"
|
||||||
|
loop: ["", "minute"]
|
||||||
changed_when: false
|
changed_when: false
|
||||||
failed_when: false
|
failed_when: false
|
||||||
|
|
|
||||||
48
roles/client_sante/tasks/retirer.yml
Normal file
48
roles/client_sante/tasks/retirer.yml
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
---
|
||||||
|
# DEFAIRE CE QUE CE ROLE A POSE, SUR UN HOTE QUI N'EST PLUS DANS SON GROUPE (2026-10-03).
|
||||||
|
#
|
||||||
|
# Le cas qui l'a fait ecrire : les trois hyperviseurs du site. Ils ont recu `client_sante`
|
||||||
|
# le 2026-09-10, puis en sont sortis le jour meme — la route par defaut gelee (D-57)
|
||||||
|
# empeche un hyperviseur de joindre Icinga, et leurs metriques sont depuis TIREES par
|
||||||
|
# Prometheus (voir `scripts/site_inventaire.py`). Sortis du groupe, plus aucun play ne les
|
||||||
|
# touchait : le minuteur est reste, pointe sur `10.0.36.11`, l'adresse de `site-mon-01`
|
||||||
|
# d'avant la renumerotation.
|
||||||
|
#
|
||||||
|
# Mesure du 2026-10-03 : un echec toutes les 15 min depuis au moins sept jours, environ
|
||||||
|
# 2 050 par hyperviseur. Et `node_exporter` publiait sur chacun UNE unite en echec
|
||||||
|
# permanente — exactement ce que ce role refuse ailleurs (`client_sante_unites_tolerees`) :
|
||||||
|
# le jour ou une vraie unite tombe, le compte passe de 1 a 2 et personne ne le voit.
|
||||||
|
#
|
||||||
|
# ON NE RETIRE QUE CE QUE CE ROLE POSE. Les sondes de `client_journal` et de
|
||||||
|
# `client_metrique` restent dans `/usr/local/lib/setops/sondes/` : ces roles s'appliquent
|
||||||
|
# encore, ils les redeposeraient au prochain passage, et sans porteur elles sont inertes.
|
||||||
|
- name: Les unites du porteur sont-elles encore la ?
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: "/etc/systemd/system/{{ item }}"
|
||||||
|
loop: "{{ client_sante_unites_posees }}"
|
||||||
|
register: client_sante_unites_restantes
|
||||||
|
|
||||||
|
- name: Arreter et desactiver les minuteurs restes en place
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: "{{ item.item }}"
|
||||||
|
state: stopped
|
||||||
|
enabled: false
|
||||||
|
loop: "{{ client_sante_unites_restantes.results }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.item }}"
|
||||||
|
when:
|
||||||
|
- item.stat.exists
|
||||||
|
- item.item is search('\.timer$')
|
||||||
|
|
||||||
|
- name: Retirer les unites, le script et les secrets du rapport passif
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: absent
|
||||||
|
loop: >-
|
||||||
|
{{ client_sante_unites_posees | map('regex_replace', '^', '/etc/systemd/system/') | list
|
||||||
|
+ ['/usr/local/sbin/setops-sante.sh',
|
||||||
|
'/etc/setops/icinga-api.pass',
|
||||||
|
client_sante_ca_verification,
|
||||||
|
'/usr/local/lib/setops/sondes/disque.sh',
|
||||||
|
'/usr/local/lib/setops/sondes/plancher.sh'] }}
|
||||||
|
notify: Oublier l'echec du porteur retire
|
||||||
|
|
@ -109,9 +109,9 @@ echecs=("${restantes[@]}")
|
||||||
# sortirait en 127 et Icinga recevrait un code hors bornes — ou rien. On rapporte donc
|
# sortirait en 127 et Icinga recevrait un code hors bornes — ou rien. On rapporte donc
|
||||||
# CRITIQUE, en le disant : le pire serait qu'un script mort produise un service muet, que
|
# CRITIQUE, en le disant : le pire serait qu'un script mort produise un service muet, que
|
||||||
# le `ttl` finirait par perimer sans que personne sache pourquoi.
|
# le `ttl` finirait par perimer sans que personne sache pourquoi.
|
||||||
sondes() {
|
sondes() { # $1=repertoire $2=ttl des resultats
|
||||||
local f nom sortie code
|
local f nom sortie code
|
||||||
for f in /usr/local/lib/setops/sondes/*.sh; do
|
for f in "$1"/*.sh; do
|
||||||
[[ -x "$f" ]] || continue
|
[[ -x "$f" ]] || continue
|
||||||
nom=$(basename "$f" .sh)
|
nom=$(basename "$f" .sh)
|
||||||
# DELAI DE GARDE SUR CHAQUE SONDE — mesure du 2026-09-09.
|
# DELAI DE GARDE SUR CHAQUE SONDE — mesure du 2026-09-09.
|
||||||
|
|
@ -125,21 +125,31 @@ sondes() {
|
||||||
# INCONNU (3) en le disant, plutot que de laisser le porteur mourir en silence.
|
# INCONNU (3) en le disant, plutot que de laisser le porteur mourir en silence.
|
||||||
sortie=$(timeout {{ client_sante_delai_sonde }} "$f" 2>&1); code=$?
|
sortie=$(timeout {{ client_sante_delai_sonde }} "$f" 2>&1); code=$?
|
||||||
if (( code == 124 )); then
|
if (( code == 124 )); then
|
||||||
rapporter "$nom" 3 "Sonde ${nom} sans reponse apres {{ client_sante_delai_sonde }} s — aucun verdict." "{{ client_sante_ttl_sondes }}"
|
rapporter "$nom" 3 "Sonde ${nom} sans reponse apres {{ client_sante_delai_sonde }} s — aucun verdict." "$2"
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
if (( code > 3 )); then
|
if (( code > 3 )); then
|
||||||
rapporter "$nom" 2 "Sonde ${nom} en erreur (code ${code}) : ${sortie:0:160}" "{{ client_sante_ttl_sondes }}"
|
rapporter "$nom" 2 "Sonde ${nom} en erreur (code ${code}) : ${sortie:0:160}" "$2"
|
||||||
else
|
else
|
||||||
rapporter "$nom" "$code" "${sortie:-(aucun message)}" "{{ client_sante_ttl_sondes }}"
|
rapporter "$nom" "$code" "${sortie:-(aucun message)}" "$2"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# LE MODE MINUTE (2026-09-30). Certaines sondes doivent battre a la minute — `connectivite`,
|
||||||
|
# qui dit qu'un flux est coupe des qu'il l'est. Elles vivent dans leur propre repertoire,
|
||||||
|
# HORS de celui-ci : sinon ce rapport-ci les rejouerait au quart d'heure avec un `ttl` de
|
||||||
|
# 90 min, et leur silence ne se verrait plus avant une heure et demie. Meme rapporteur, meme
|
||||||
|
# compte, aucun second script a tenir aligne.
|
||||||
|
if [[ "${1:-}" == "minute" ]]; then
|
||||||
|
sondes "{{ client_sante_repertoire_minute }}" "{{ client_sante_ttl_minute }}"
|
||||||
|
exit $(( ECHECS_RAPPORT > 0 ? 1 : 0 ))
|
||||||
|
fi
|
||||||
|
|
||||||
n=${#echecs[@]}
|
n=${#echecs[@]}
|
||||||
if [[ $n -eq 0 ]]; then
|
if [[ $n -eq 0 ]]; then
|
||||||
rapporter "sante" 0 "Aucune unite systemd en echec." "${TTL}"
|
rapporter "sante" 0 "Aucune unite systemd en echec." "${TTL}"
|
||||||
sondes
|
sondes /usr/local/lib/setops/sondes "{{ client_sante_ttl_sondes }}"
|
||||||
exit $(( ECHECS_RAPPORT > 0 ? 1 : 0 ))
|
exit $(( ECHECS_RAPPORT > 0 ? 1 : 0 ))
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -147,5 +157,5 @@ fi
|
||||||
# probleme, soit du bruit qu'il faut retirer : dans les deux cas il faut agir. Un seuil
|
# probleme, soit du bruit qu'il faut retirer : dans les deux cas il faut agir. Un seuil
|
||||||
# ferait vivre le bruit indefiniment, et c'est exactement ce qu'on vient de corriger.
|
# ferait vivre le bruit indefiniment, et c'est exactement ce qu'on vient de corriger.
|
||||||
rapporter "sante" 2 "$n unite(s) systemd en echec : ${echecs[*]}" "${TTL}"
|
rapporter "sante" 2 "$n unite(s) systemd en echec : ${echecs[*]}" "${TTL}"
|
||||||
sondes
|
sondes /usr/local/lib/setops/sondes "{{ client_sante_ttl_sondes }}"
|
||||||
exit $(( ECHECS_RAPPORT > 0 ? 1 : 0 ))
|
exit $(( ECHECS_RAPPORT > 0 ? 1 : 0 ))
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
# GENERE par Set-OPS (role client_sante).
|
||||||
|
[Unit]
|
||||||
|
Description=Rapporter a Icinga les sondes a la minute (connectivite...)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/sbin/setops-sante.sh minute
|
||||||
14
roles/client_sante/templates/setops-sondes-minute.timer.j2
Normal file
14
roles/client_sante/templates/setops-sondes-minute.timer.j2
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# GENERE par Set-OPS (role client_sante).
|
||||||
|
[Unit]
|
||||||
|
Description=Sondes a la minute (connectivite...)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=1min
|
||||||
|
OnUnitActiveSec=60s
|
||||||
|
AccuracySec=5s
|
||||||
|
# Dispersion courte : toute la flotte ne frappe pas l'API a la meme seconde, et le
|
||||||
|
# rapport reste bien en deca du `ttl` (3 min).
|
||||||
|
RandomizedDelaySec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
96
roles/client_sante/templates/sonde-plancher.sh.j2
Normal file
96
roles/client_sante/templates/sonde-plancher.sh.j2
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# GENERE par Set-OPS (role client_sante). Ne pas editer a la main.
|
||||||
|
#
|
||||||
|
# SONDE « plancher » — le plancher /etc/hosts de cette machine dit-il la meme chose que
|
||||||
|
# l'autoritatif du domaine ?
|
||||||
|
#
|
||||||
|
# POURQUOI (2026-09-29). Le plancher et la zone PowerDNS derivent du meme plan, mais
|
||||||
|
# chacun ne le suit que quand son role est rejoue. La console a ete ajoutee au plan : la
|
||||||
|
# zone l'a recue, les planchers non — sauf celui d'`ops-01`, redeploye depuis. Depuis
|
||||||
|
# l'edge, `console` ne se resolvait pas, et rien ne le signalait. Sur ces machines, le
|
||||||
|
# plancher est la SEULE resolution des noms internes : un nom qui y manque n'existe pas.
|
||||||
|
#
|
||||||
|
# COMMENT, SANS TRANSFERT DE ZONE. La zone publie `_plancher.<domaine> TXT "n=… sha256=…"`
|
||||||
|
# : le nombre et l'empreinte des paires « nom adresse » que le plancher doit porter pour ce
|
||||||
|
# domaine. On calcule la meme chose ici sur /etc/hosts. Si elles different, on nomme ce
|
||||||
|
# qu'on peut : chaque nom du plancher est demande a l'autoritatif (en trop, readresse), et
|
||||||
|
# l'ecart de nombre dit combien de noms publies manquent ici.
|
||||||
|
#
|
||||||
|
# L'autoritatif est interroge DIRECTEMENT : le resolveur qu'utilisent ces machines peut
|
||||||
|
# ignorer ce domaine (c'est le cas chez les locataires, qui passent par celui du site).
|
||||||
|
#
|
||||||
|
# Mise en defaut PAR PARAMETRE : `client_sante_plancher_fichier` (un plancher vide).
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
DOMAINE='{{ domaine_interne | default('') }}'
|
||||||
|
AUTORITE='{{ hostvars[groups['serveur_powerdns'][0]].ansible_host | default('') if (groups.get('serveur_powerdns') or []) else '' }}'
|
||||||
|
FICHIER='{{ client_sante_plancher_fichier }}'
|
||||||
|
|
||||||
|
if [[ -z "${DOMAINE}" || -z "${AUTORITE}" ]]; then
|
||||||
|
echo "Pas d'autoritatif interne dans cet ecosysteme : rien a quoi comparer le plancher."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
txt=$(dig +short +time=3 +tries=2 TXT "_plancher.${DOMAINE}" "@${AUTORITE}" 2>/dev/null | tr -d '"')
|
||||||
|
if [[ "${txt}" != n=*" sha256="* ]]; then
|
||||||
|
echo "L'autoritatif (${AUTORITE}) ne publie pas l'empreinte du plancher (_plancher.${DOMAINE} : ${txt:-rien}) — zone anterieure, ou injoignable : comparaison impossible."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Les paires « nom adresse » du plancher pour CE domaine : toute ligne, tout nom qui finit
|
||||||
|
# par `.<domaine>` (le nom court des hotes, en alias, n'est pas compte ; la zone non plus).
|
||||||
|
local_=$(python3 - "${FICHIER}" "${DOMAINE}" <<'PY'
|
||||||
|
import hashlib, sys
|
||||||
|
fichier, domaine = sys.argv[1], sys.argv[2]
|
||||||
|
paires = set()
|
||||||
|
try:
|
||||||
|
lignes = open(fichier, encoding="utf-8").read().splitlines()
|
||||||
|
except OSError:
|
||||||
|
lignes = []
|
||||||
|
for l in lignes:
|
||||||
|
champs = l.split("#", 1)[0].split()
|
||||||
|
if len(champs) < 2:
|
||||||
|
continue
|
||||||
|
for nom in champs[1:]:
|
||||||
|
if nom.endswith("." + domaine):
|
||||||
|
paires.add(f"{nom} {champs[0]}")
|
||||||
|
tri = sorted(paires)
|
||||||
|
print(f"n={len(tri)} sha256={hashlib.sha256(chr(10).join(tri).encode()).hexdigest()}")
|
||||||
|
for p in tri:
|
||||||
|
print(p)
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
empreinte=$(printf '%s\n' "${local_}" | head -1)
|
||||||
|
n_pub=${txt#n=}; n_pub=${n_pub%% *}
|
||||||
|
n_loc=${empreinte#n=}; n_loc=${n_loc%% *}
|
||||||
|
perf="| noms=${n_loc};;;0; publies=${n_pub};;;0;"
|
||||||
|
|
||||||
|
if [[ "${empreinte}" == "${txt}" ]]; then
|
||||||
|
echo "Plancher conforme a l'autoritatif : ${n_loc} nom(s) de ${DOMAINE}, memes adresses. ${perf}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ILS DIFFERENT : on nomme ce qu'on peut. Chaque nom du plancher est demande a l'autoritatif.
|
||||||
|
# Un nom qu'il ignore est en trop ici (ou une machine planifiee, que la zone ne publie pas
|
||||||
|
# encore) ; une autre adresse est un readressage que l'un des deux n'a pas suivi.
|
||||||
|
en_trop=(); readresses=(); connus=0; i=0
|
||||||
|
while read -r nom ip; do
|
||||||
|
(( i++ >= 40 )) && break
|
||||||
|
pub=$(dig +short +time=2 +tries=1 A "${nom}" "@${AUTORITE}" 2>/dev/null | tail -1)
|
||||||
|
if [[ -z "${pub}" ]]; then en_trop+=("${nom%%.*}")
|
||||||
|
elif [[ "${pub}" != "${ip}" ]]; then readresses+=("${nom%%.*}=${ip}/${pub}"); connus=$((connus + 1))
|
||||||
|
else connus=$((connus + 1)); fi
|
||||||
|
done < <(printf '%s\n' "${local_}" | tail -n +2)
|
||||||
|
manquants=$(( n_pub - connus ))
|
||||||
|
|
||||||
|
msg="Le plancher de cette machine et l'autoritatif divergent (${n_loc} nom(s) ici, ${n_pub} publie(s))"
|
||||||
|
[[ ${manquants} -gt 0 ]] && msg+=" ; ${manquants} nom(s) publie(s) absent(s) d'ici"
|
||||||
|
[[ -n "${readresses[*]}" ]] && msg+=" ; autre adresse : ${readresses[*]}"
|
||||||
|
[[ -n "${en_trop[*]}" ]] && msg+=" ; inconnu(s) de l'autoritatif : ${en_trop[*]}"
|
||||||
|
msg+=". Rejouer hosts_statiques (ou serveur_powerdns, si c'est la zone qui date)."
|
||||||
|
# Un nom qui MANQUE ou une adresse qui differe casse une resolution : critique. Des noms en
|
||||||
|
# trop seulement (une machine retiree du plan) ne cassent rien : avertissement.
|
||||||
|
if (( manquants > 0 )) || [[ -n "${readresses[*]}" ]]; then
|
||||||
|
echo "${msg} ${perf}"; exit 2
|
||||||
|
fi
|
||||||
|
echo "${msg} ${perf}"; exit 1
|
||||||
|
|
@ -6,6 +6,29 @@
|
||||||
#
|
#
|
||||||
# Attendre ici plutot que seulement avant le playbook : le verrou peut etre repris
|
# Attendre ici plutot que seulement avant le playbook : le verrou peut etre repris
|
||||||
# ENTRE deux taches, ce qu'une verification ponctuelle en amont ne peut pas empecher.
|
# ENTRE deux taches, ce qu'une verification ponctuelle en amont ne peut pas empecher.
|
||||||
|
# APT-GET ATTEND LE VERROU, LUI AUSSI (2026-09-30).
|
||||||
|
#
|
||||||
|
# `lock_timeout` ne protege que la verification faite par le MODULE apt. Pour une mise a
|
||||||
|
# niveau, le module lance ensuite `apt-get dist-upgrade` — qui, lui, n'attend pas : APT 3.0
|
||||||
|
# ne donne d'attente (120 s) qu'a la commande `apt` (`Binary::apt::DPkg::Lock::Timeout`),
|
||||||
|
# pas a `apt-get`. Entre la verification et l'appel, `unattended-upgrades` a repris le
|
||||||
|
# verrou sur `mon-01`, ne une minute plus tot, et la reconstruction de Technolibre s'est
|
||||||
|
# arretee :
|
||||||
|
#
|
||||||
|
# E: Could not get lock /var/lib/dpkg/lock-frontend. It is held by process 1198 (unattended-upgr)
|
||||||
|
#
|
||||||
|
# On le dit donc a APT LUI-MEME, pour tout appel : c'est un fichier, pas une operation apt,
|
||||||
|
# il peut passer en tout premier.
|
||||||
|
- name: Faire attendre le verrou dpkg a tout appel d'APT (apt-get compris)
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/apt/apt.conf.d/10setops-verrou
|
||||||
|
content: |
|
||||||
|
// Gere par Set-OPS (role common_packages). Voir tasks/main.yml.
|
||||||
|
DPkg::Lock::Timeout "{{ common_packages_lock_timeout }}";
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
|
||||||
# DÉSARMER AVANT TOUTE OPÉRATION APT, ET NON APRÈS.
|
# DÉSARMER AVANT TOUTE OPÉRATION APT, ET NON APRÈS.
|
||||||
#
|
#
|
||||||
# Une tâche apt placée avant celle-ci attendrait le verrou que celle-ci doit justement
|
# Une tâche apt placée avant celle-ci attendrait le verrou que celle-ci doit justement
|
||||||
|
|
@ -93,6 +116,18 @@
|
||||||
cache_valid_time: 3600
|
cache_valid_time: 3600
|
||||||
lock_timeout: "{{ common_packages_lock_timeout }}"
|
lock_timeout: "{{ common_packages_lock_timeout }}"
|
||||||
|
|
||||||
|
# LES ÉPINGLES AVANT LA MISE À JOUR (2026-10-03). `upgrade: full` montait les paquets
|
||||||
|
# tiers au-dela de `paquets-tiers.yml` : au site, `icinga-php-thirdparty` etait passe de
|
||||||
|
# 1.0.0 (pose par le cache) a 1.0.1 sans que personne l'ait decide. Les preferences apt de
|
||||||
|
# TOUTES les epingles sont posees ici, sur toute machine du socle : une epingle sur un
|
||||||
|
# paquet absent ne fait rien, et fixe la version de sa premiere installation.
|
||||||
|
- name: Épingler tous les paquets tiers avant la mise à jour
|
||||||
|
ansible.builtin.include_role:
|
||||||
|
name: paquets_tiers
|
||||||
|
tasks_from: epingles.yml
|
||||||
|
vars:
|
||||||
|
paquets_tiers_toutes_epingles: true
|
||||||
|
|
||||||
- name: Appliquer les mises à jour disponibles
|
- name: Appliquer les mises à jour disponibles
|
||||||
ansible.builtin.apt:
|
ansible.builtin.apt:
|
||||||
upgrade: full
|
upgrade: full
|
||||||
|
|
@ -124,11 +159,16 @@
|
||||||
|
|
||||||
- name: Assurer le repertoire d etat des sondes
|
- name: Assurer le repertoire d etat des sondes
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
|
# 0755, COMME PARTOUT AILLEURS (2026-10-01) : quatre roles tenaient ce repertoire, trois
|
||||||
|
# en 0750 et `serveur_artefacts` en 0755 — apt-cacher-ng doit le traverser pour servir
|
||||||
|
# le depot de binaires du site. Le dernier deploye gagnait : au site, le 2026-10-01, il
|
||||||
|
# rendait 403, et chaque runner allait chercher 500 Mo sur Internet. Le repertoire ne
|
||||||
|
# tient que des etats de sondes, deja lisibles. Garde : test_repertoires_partages.py.
|
||||||
path: /var/lib/setops
|
path: /var/lib/setops
|
||||||
state: directory
|
state: directory
|
||||||
owner: root
|
owner: root
|
||||||
group: root
|
group: root
|
||||||
mode: "0750"
|
mode: "0755"
|
||||||
|
|
||||||
- name: Deposer la sonde des correctifs de securite
|
- name: Deposer la sonde des correctifs de securite
|
||||||
ansible.builtin.template:
|
ansible.builtin.template:
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,12 @@ ff02::2 ip6-allrouters
|
||||||
{% if hosts_statiques_publier_expositions | default(false) and hosts_statiques_expositions %}
|
{% if hosts_statiques_publier_expositions | default(false) and hosts_statiques_expositions %}
|
||||||
|
|
||||||
# --- Alias d'exposition (FQDN publics → l'edge qui les sert) ---
|
# --- Alias d'exposition (FQDN publics → l'edge qui les sert) ---
|
||||||
|
{# `resout_vers: service` — entre machines on va au service, l'edge sert aux personnes
|
||||||
|
(voir `expositions_des_applications`). La zone PowerDNS suit la MEME regle. #}
|
||||||
{% for expo in hosts_statiques_expositions %}
|
{% for expo in hosts_statiques_expositions %}
|
||||||
{% set edge_hosts = groups.get(expo.edge, []) %}
|
{% set cibles = [expo.hote] if expo.resout_vers | default('edge') == 'service' else groups.get(expo.edge, []) %}
|
||||||
{% if edge_hosts and hostvars[edge_hosts[0]].ansible_host is defined %}
|
{% if cibles and cibles[0] in hostvars and hostvars[cibles[0]].ansible_host is defined %}
|
||||||
{{ "%-15s" | format(hostvars[edge_hosts[0]].ansible_host) }} {{ expo.fqdn }}
|
{{ "%-15s" | format(hostvars[cibles[0]].ansible_host) }} {{ expo.fqdn }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -13,3 +13,16 @@ nftables_baseline_extra_udp_ports: []
|
||||||
# source (ip saddr = moindre privilège), dérivées des meta/flux.yml de l'hôte. S'il
|
# source (ip saddr = moindre privilège), dérivées des meta/flux.yml de l'hôte. S'il
|
||||||
# est présent sur le contrôleur, il PRIME sur le gabarit plat ci-dessus.
|
# est présent sur le contrôleur, il PRIME sur le gabarit plat ci-dessus.
|
||||||
nftables_baseline_ruleset_genere: "{{ inventory_dir }}/../../flux-genere/{{ inventory_hostname }}.nft"
|
nftables_baseline_ruleset_genere: "{{ inventory_dir }}/../../flux-genere/{{ inventory_hostname }}.nft"
|
||||||
|
|
||||||
|
# La liste des flux que la sonde `connectivite` teste chaque minute, generee par `make flux`
|
||||||
|
# a cote du ruleset, depuis les MEMES regles (voir templates/sonde-connectivite.sh.j2).
|
||||||
|
nftables_baseline_connectivite_genere: "{{ inventory_dir }}/../../flux-genere/{{ inventory_hostname }}.connectivite.json"
|
||||||
|
|
||||||
|
# LA PASSERELLE N'EST PAS UNE PORTE (2026-10-05). Ports qui ne doivent JAMAIS repondre sur la
|
||||||
|
# passerelle par defaut de la machine — la sonde `connectivite` le verifie chaque minute.
|
||||||
|
# Chez un locataire en SDN, la passerelle de zone est l'HYPERVISEUR lui-meme, dans le VRF du
|
||||||
|
# locataire : une API Proxmox qui y repondrait contournerait la frontiere. Fermee aujourd'hui
|
||||||
|
# (mesure du 2026-10-05 : ECONNREFUSED) ; la garde dit si elle s'ouvre, quelle qu'en soit la
|
||||||
|
# cause (`net.ipv4.tcp_l3mdev_accept`, un pare-feu d'hote retire...). Sur une machine du site,
|
||||||
|
# la passerelle est la frontiere, qui n'ecoute pas sur ces ports.
|
||||||
|
nftables_baseline_connectivite_passerelle_interdits: [8006]
|
||||||
|
|
|
||||||
|
|
@ -80,3 +80,49 @@
|
||||||
state: stopped
|
state: stopped
|
||||||
when: not (nftables_baseline_enabled | bool)
|
when: not (nftables_baseline_enabled | bool)
|
||||||
failed_when: false
|
failed_when: false
|
||||||
|
|
||||||
|
# --- LA SONDE « connectivite » (2026-09-30) ------------------------------------------------
|
||||||
|
#
|
||||||
|
# Ce que ce pare-feu promet, verifie chaque minute depuis la machine qui doit pouvoir
|
||||||
|
# joindre — et non plus seulement au moment d'une activation. Deposee dans le repertoire
|
||||||
|
# des sondes A LA MINUTE, que le porteur `client_sante` joue en mode `minute`.
|
||||||
|
- name: La liste des flux de cet hote est-elle generee ?
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: "{{ nftables_baseline_connectivite_genere }}"
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
changed_when: false
|
||||||
|
register: nftables_baseline_connectivite_stat
|
||||||
|
|
||||||
|
- name: Assurer le repertoire des sondes a la minute
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /usr/local/lib/setops/sondes-minute
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
# PAS DANS /etc/setops : cette liste n'a rien de secret, et ce repertoire-la — celui des
|
||||||
|
# secrets — a deja ses gardiens (0700 chez les locataires, 0755 au site). Un troisieme avis
|
||||||
|
# sur ses droits le faisait basculer a chaque passage (vu en simulation au site).
|
||||||
|
- name: Deposer la liste des flux a tester
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: "{{ nftables_baseline_connectivite_genere }}"
|
||||||
|
dest: /usr/local/lib/setops/connectivite.json
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
when: nftables_baseline_connectivite_stat.stat.exists
|
||||||
|
|
||||||
|
- name: Retirer l'ancien emplacement de la liste (premier deploiement, 2026-09-30)
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /etc/setops/connectivite.json
|
||||||
|
state: absent
|
||||||
|
|
||||||
|
- name: Deposer la sonde « connectivite »
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: sonde-connectivite.sh.j2
|
||||||
|
dest: /usr/local/lib/setops/sondes-minute/connectivite.sh
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0750"
|
||||||
|
|
|
||||||
189
roles/nftables_baseline/templates/sonde-connectivite.sh.j2
Normal file
189
roles/nftables_baseline/templates/sonde-connectivite.sh.j2
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# GENERE par Set-OPS (role nftables_baseline). Ne pas editer a la main.
|
||||||
|
#
|
||||||
|
# SONDE « connectivite » — chaque flux SORTANT que le registre des flux promet a cette
|
||||||
|
# machine passe-t-il, en ce moment ? Rapportee chaque minute (porteur `client_sante`, mode
|
||||||
|
# `minute`) : un flux coupe se voit dans la minute, pas au prochain deploiement.
|
||||||
|
#
|
||||||
|
# POURQUOI (2026-09-30). L'activation du pare-feu Proxmox se prouvait par une matrice rejouee
|
||||||
|
# VM par VM, avant et apres : vingt minutes en fin de reconstruction, et une preuve valable
|
||||||
|
# a l'instant de l'activation seulement. Cette sonde la remplace, et veille ensuite en
|
||||||
|
# permanence.
|
||||||
|
#
|
||||||
|
# LA LISTE n'est pas ecrite ici : `make flux` la derive des MEMES regles resolues que le
|
||||||
|
# `.nft` de chaque hote (`flux-genere/<hote>.connectivite.json`). On teste ce que le pare-feu
|
||||||
|
# applique, pas ce qu'on croit.
|
||||||
|
#
|
||||||
|
# CE QUE L'ERRNO DIT — mesure du 2026-09-30, depuis web-frontal-01 de Technolibre :
|
||||||
|
# ouvert le flux passe -> OK
|
||||||
|
# ECONNREFUSED REFUSE : personne n'ecoute, OU la frontiere refuse -> AVERTISSEMENT
|
||||||
|
# Depuis le 2026-10-01 les interfaces internes de la frontiere rendent un
|
||||||
|
# `reject` — un RST pour TCP, indiscernable d'un port ferme. Et depuis les
|
||||||
|
# `seulement_si`, aucun flux declare ne mene plus a un port sans service :
|
||||||
|
# ce cas n'est plus jamais normal. Jaune, pas rouge — un service qui
|
||||||
|
# redemarre ne doit pas reveiller quelqu'un.
|
||||||
|
# EHOSTUNREACH REJETE (pare-feu Proxmox : icmp host-prohibited), ou
|
||||||
|
# machine injoignable -> CRITIQUE
|
||||||
|
# delai JETE en silence (politique `drop` de nftables) -> CRITIQUE
|
||||||
|
# Un `connect()` TCP ne prouve pas une livraison au-dela d'un relais ; ici les flux sont
|
||||||
|
# directs (meme locataire, routes par la frontiere sans relais applicatif), et c'est bien
|
||||||
|
# le passage des pare-feux qu'on mesure.
|
||||||
|
#
|
||||||
|
# CE QU'ELLE OBSERVE AUSSI : les connexions entrantes ETABLIES que les entrees declarees ne
|
||||||
|
# couvrent pas (AVERTISSEMENT). Un flux reel non declare serait coupe a la prochaine regle
|
||||||
|
# posee : mieux vaut l'apprendre avant.
|
||||||
|
#
|
||||||
|
# CE QU'ELLE GARDE AUSSI (2026-10-05) : la passerelle par defaut ne doit repondre sur aucun
|
||||||
|
# port de `nftables_baseline_connectivite_passerelle_interdits` (l'API Proxmox, 8006). Chez un
|
||||||
|
# locataire en SDN, cette passerelle est l'hyperviseur, dans le VRF du locataire ; une API qui y
|
||||||
|
# repondrait contournerait la frontiere. Ici, `ouvert` prouve bien quelque chose : entre la
|
||||||
|
# machine et sa passerelle, aucun relais. Ouvert -> CRITIQUE ; refuse, rejete, delai -> attendu.
|
||||||
|
# Passerelle introuvable -> AVERTISSEMENT : une garde qui ne s'exerce pas doit le dire.
|
||||||
|
#
|
||||||
|
# Contrat : une ligne, code 0/1/2 (docs/supervision-conception.md).
|
||||||
|
# `CONNECTIVITE_JSON=<fichier>` impose une autre liste : c'est le controle negatif.
|
||||||
|
# `CONNECTIVITE_PASSERELLE=<adresse>` vise une autre « passerelle » : le controle negatif de la
|
||||||
|
# garde (une adresse dont un port interdit repond doit la faire crier).
|
||||||
|
exec python3 - "${CONNECTIVITE_JSON:-/usr/local/lib/setops/connectivite.json}" <<'PY'
|
||||||
|
import concurrent.futures, errno, ipaddress, json, os, socket, subprocess, sys
|
||||||
|
|
||||||
|
INTERDITS = {{ nftables_baseline_connectivite_passerelle_interdits | to_json }}
|
||||||
|
|
||||||
|
try:
|
||||||
|
d = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
print(f"Liste des flux absente ou illisible ({sys.argv[1]}) : rien n'est teste. "
|
||||||
|
"Regenerer `make flux` et redeployer nftables_baseline.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def tcp(ip, port):
|
||||||
|
s = socket.socket()
|
||||||
|
s.settimeout(2)
|
||||||
|
try:
|
||||||
|
s.connect((ip, int(port)))
|
||||||
|
return "ouvert"
|
||||||
|
except socket.timeout:
|
||||||
|
return "delai"
|
||||||
|
except OSError as e:
|
||||||
|
if e.errno == errno.ECONNREFUSED:
|
||||||
|
return "refuse"
|
||||||
|
if e.errno in (errno.EHOSTUNREACH, errno.ENETUNREACH, errno.EACCES, errno.EPERM):
|
||||||
|
return "rejete"
|
||||||
|
return errno.errorcode.get(e.errno, str(e.errno))
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
|
def ping(ip):
|
||||||
|
r = subprocess.run(["ping", "-c1", "-W2", ip], capture_output=True)
|
||||||
|
return "ouvert" if r.returncode == 0 else "delai"
|
||||||
|
|
||||||
|
|
||||||
|
def essai(f):
|
||||||
|
return f, (ping(f["ip"]) if f["proto"] == "icmp" else tcp(f["ip"], f["port"]))
|
||||||
|
|
||||||
|
|
||||||
|
sorties = d.get("sorties") or []
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=16) as ex:
|
||||||
|
resultats = list(ex.map(essai, sorties))
|
||||||
|
|
||||||
|
coupes = [(f, r) for f, r in resultats if r not in ("ouvert", "refuse")]
|
||||||
|
|
||||||
|
# La passerelle n'est pas une porte.
|
||||||
|
def passerelle_par_defaut():
|
||||||
|
if os.environ.get("CONNECTIVITE_PASSERELLE"):
|
||||||
|
return os.environ["CONNECTIVITE_PASSERELLE"]
|
||||||
|
try:
|
||||||
|
for l in subprocess.run(["ip", "-4", "route", "show", "default"], capture_output=True,
|
||||||
|
text=True, timeout=5).stdout.splitlines():
|
||||||
|
c = l.split()
|
||||||
|
if "via" in c:
|
||||||
|
return c[c.index("via") + 1]
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
gw = passerelle_par_defaut()
|
||||||
|
porte = [p for p in INTERDITS if gw and tcp(gw, p) == "ouvert"]
|
||||||
|
personne = [(f, r) for f, r in resultats if r == "refuse"]
|
||||||
|
ouverts = len(resultats) - len(coupes) - len(personne)
|
||||||
|
|
||||||
|
# Entrees etablies hors des regles declarees.
|
||||||
|
def lignes(cmd):
|
||||||
|
try:
|
||||||
|
return subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout.splitlines()
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return []
|
||||||
|
|
||||||
|
ecoute = {l.split()[3].rsplit(":", 1)[1] for l in lignes(["ss", "-Hltn"]) if len(l.split()) >= 4}
|
||||||
|
# MES PROPRES ADRESSES : une machine qui se parle a elle-meme par son adresse (Loki sur
|
||||||
|
# obs-01, port 9096) passe par la boucle locale, jamais par un pare-feu. Vu au premier
|
||||||
|
# deploiement : `obs-01` se declarait « hors des regles » envers lui-meme.
|
||||||
|
moi = set()
|
||||||
|
for l in lignes(["ip", "-o", "addr", "show"]):
|
||||||
|
c = l.split()
|
||||||
|
if len(c) >= 4 and c[2] in ("inet", "inet6"):
|
||||||
|
moi.add(c[3].split("/")[0])
|
||||||
|
reseaux = {}
|
||||||
|
for e in d.get("entrees") or []:
|
||||||
|
if e.get("proto") == "tcp":
|
||||||
|
reseaux.setdefault(str(e["port"]), []).extend(e.get("sources") or [])
|
||||||
|
reseaux.setdefault("22", []).extend(d.get("admin_ssh") or [])
|
||||||
|
hors = set()
|
||||||
|
for l in lignes(["ss", "-Hnt", "state", "established"]):
|
||||||
|
c = l.split()
|
||||||
|
if len(c) < 4:
|
||||||
|
continue
|
||||||
|
lport = c[2].rsplit(":", 1)[1]
|
||||||
|
pair = c[3].rsplit(":", 1)[0].strip("[]").replace("::ffff:", "")
|
||||||
|
if lport not in ecoute:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
adr = ipaddress.ip_address(pair)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if adr.is_loopback or str(adr) in moi:
|
||||||
|
continue
|
||||||
|
permis = False
|
||||||
|
for r in reseaux.get(lport, []):
|
||||||
|
try:
|
||||||
|
if adr in ipaddress.ip_network(str(r), strict=False):
|
||||||
|
permis = True
|
||||||
|
break
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if not permis:
|
||||||
|
hors.add(f"{pair}->{lport}")
|
||||||
|
|
||||||
|
def nomme(xs):
|
||||||
|
return ", ".join(f"{f['vers']}:{f['port'] if f['proto'] != 'icmp' else 'ping'} ({r})" for f, r in xs[:5]) \
|
||||||
|
+ (f" et {len(xs) - 5} autre(s)" if len(xs) > 5 else "")
|
||||||
|
|
||||||
|
perf = (f"| ouverts={ouverts};;;0 coupes={len(coupes)};;;0 sans_service={len(personne)};;;0 "
|
||||||
|
f"hors_regles={len(hors)};;;0 passerelle_ouverte={len(porte)};;;0")
|
||||||
|
if porte:
|
||||||
|
print(f"PASSERELLE OUVERTE : {gw} repond sur {', '.join(map(str, porte))} — la passerelle de zone "
|
||||||
|
f"(l'hyperviseur, chez un locataire) expose ce qui doit rester derriere la frontiere. {perf}")
|
||||||
|
sys.exit(2)
|
||||||
|
if coupes:
|
||||||
|
print(f"COUPE : {len(coupes)}/{len(resultats)} flux sortant(s) ne passent pas — {nomme(coupes)}. {perf}")
|
||||||
|
sys.exit(2)
|
||||||
|
avert = []
|
||||||
|
info = ""
|
||||||
|
if INTERDITS and not gw:
|
||||||
|
avert.append("passerelle par defaut introuvable — la garde de la passerelle ne s'exerce pas")
|
||||||
|
if personne:
|
||||||
|
avert.append(f"{len(personne)} flux refuse(s) — service absent, ou refus de la frontiere — "
|
||||||
|
f"{nomme(personne)}")
|
||||||
|
if hors:
|
||||||
|
avert.append(f"{len(hors)} connexion(s) entrante(s) hors des regles declarees — "
|
||||||
|
+ ", ".join(sorted(hors)[:5]))
|
||||||
|
if avert:
|
||||||
|
print(f"{ouverts}/{len(resultats)} flux sortants ouverts{info} ; " + " ; ".join(avert) + f". {perf}")
|
||||||
|
sys.exit(1)
|
||||||
|
if not resultats:
|
||||||
|
print(f"Aucun flux sortant declare pour {d.get('hote', 'cet hote')} ; aucune entree hors des regles. {perf}")
|
||||||
|
sys.exit(0)
|
||||||
|
print(f"Connectivite : {ouverts}/{len(resultats)} flux sortants ouverts{info} ; aucune entree hors des regles. {perf}")
|
||||||
|
PY
|
||||||
|
|
@ -20,3 +20,19 @@ paquets_tiers_noms: []
|
||||||
# télécharge une fois depuis un poste qui a Internet, on déploie ensuite sans en avoir.
|
# télécharge une fois depuis un poste qui a Internet, on déploie ensuite sans en avoir.
|
||||||
paquets_tiers_cache: "{{ lookup('env', 'HOME') }}/.cache/setops/paquets"
|
paquets_tiers_cache: "{{ lookup('env', 'HOME') }}/.cache/setops/paquets"
|
||||||
paquets_tiers_dest: "/var/cache/setops/paquets"
|
paquets_tiers_dest: "/var/cache/setops/paquets"
|
||||||
|
|
||||||
|
# LES ÉPINGLES S'APPLIQUENT AUSSI SANS CACHE (2026-10-03). Elles ne valaient que pour ce
|
||||||
|
# qui sortait du cache du contrôleur. Un runner — qui n'a pas ce cache — installait la
|
||||||
|
# dernière version publiée, et le `dist-upgrade` du socle (`common_packages`) montait tout
|
||||||
|
# au-delà de l'épingle, cache ou pas. Mesuré le jour même : sept paquets sur vingt et un
|
||||||
|
# plus récents que leur épingle chez les locataires, et un au site.
|
||||||
|
#
|
||||||
|
# Le fichier qui fait foi est celui du dépôt — le même que lit `make cacher-paquets`.
|
||||||
|
paquets_tiers_fichier_epingles: "{{ role_path }}/../../paquets-tiers.yml"
|
||||||
|
# 990 : apt PRÉFÈRE la version épinglée à toute version plus récente (500 par défaut), y
|
||||||
|
# monte un paquet plus ancien, mais ne RÉTROGRADE jamais un paquet déjà plus récent — ce
|
||||||
|
# que ferait 1000 et plus. Éprouvé sur `site-dns-01` et `web-dorsal-01` avant d'écrire.
|
||||||
|
paquets_tiers_priorite: 990
|
||||||
|
# `true` : épingler TOUS les paquets de paquets-tiers.yml, pas seulement `paquets_tiers_noms`.
|
||||||
|
# C'est l'appel du socle, avant son `dist-upgrade` (voir common_packages).
|
||||||
|
paquets_tiers_toutes_epingles: false
|
||||||
|
|
|
||||||
75
roles/paquets_tiers/tasks/epingles.yml
Normal file
75
roles/paquets_tiers/tasks/epingles.yml
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
---
|
||||||
|
# LES ÉPINGLES, POSÉES COMME PRÉFÉRENCES APT — cache ou pas (voir defaults/main.yml).
|
||||||
|
# Une préférence par paquet : plusieurs rôles appellent celui-ci sur la même machine, et
|
||||||
|
# un fichier commun serait réécrit par chacun avec ses seuls paquets.
|
||||||
|
- name: Lire les épingles du dépôt
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
paquets_tiers_toutes: >-
|
||||||
|
{{ (lookup('ansible.builtin.file', paquets_tiers_fichier_epingles) | from_yaml).depots
|
||||||
|
| dict2items | map(attribute='value.paquets') | list | combine }}
|
||||||
|
|
||||||
|
- name: Retenir celles de cet appel (toutes, pour le socle)
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
paquets_tiers_epingles: >-
|
||||||
|
{{ paquets_tiers_toutes if (paquets_tiers_toutes_epingles | bool)
|
||||||
|
else (paquets_tiers_toutes | dict2items | selectattr('key', 'in', paquets_tiers_noms)
|
||||||
|
| items2dict) }}
|
||||||
|
|
||||||
|
- name: Épingler les paquets tiers (préférence apt)
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "/etc/apt/preferences.d/setops-{{ item.key }}"
|
||||||
|
content: |
|
||||||
|
# Géré par Set-OPS (rôle paquets_tiers, depuis paquets-tiers.yml). Ne pas éditer.
|
||||||
|
Package: {{ item.key }}
|
||||||
|
Pin: version {{ item.value }}
|
||||||
|
Pin-Priority: {{ paquets_tiers_priorite }}
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
loop: "{{ paquets_tiers_epingles | dict2items }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.key }}={{ item.value }}"
|
||||||
|
|
||||||
|
# CE QUE L'ÉPINGLE NE PEUT PAS FAIRE, ET QU'IL FAUT DIRE. Deux cas où elle ne tient pas :
|
||||||
|
# - le paquet est DÉJÀ plus récent : 990 ne rétrograde pas (c'est voulu) — l'épingle est
|
||||||
|
# en retard sur la machine, et il faut la relever, pas laisser l'écart dormir ;
|
||||||
|
# - la version épinglée n'est PAS dans l'index du dépôt : apt ignore la préférence sans
|
||||||
|
# un mot et reprend la dernière version — la candidate est alors plus récente.
|
||||||
|
# Ce second contrôle n'a pas cours en simulation : la préférence n'y est pas écrite, et la
|
||||||
|
# candidate lue serait celle d'avant l'épingle — une fausse alerte à chaque `--check`.
|
||||||
|
- name: Comparer installé, candidat et épingle
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
set -uo pipefail
|
||||||
|
{% for nom, version in paquets_tiers_epingles.items() %}
|
||||||
|
inst=$(dpkg-query -W -f='${Version}' {{ nom }} 2>/dev/null || true)
|
||||||
|
cand=$(apt-cache policy {{ nom }} 2>/dev/null | awk '/Candidate:/ {print $2}')
|
||||||
|
if [ -n "$inst" ] && dpkg --compare-versions "$inst" gt "{{ version }}"; then
|
||||||
|
echo "PLUS_RECENT {{ nom }} installe=$inst epingle={{ version }}"
|
||||||
|
fi
|
||||||
|
{% if not ansible_check_mode %}
|
||||||
|
if [ -n "$cand" ] && [ "$cand" != "(none)" ] && dpkg --compare-versions "$cand" gt "{{ version }}" \
|
||||||
|
&& { [ -z "$inst" ] || dpkg --compare-versions "$cand" gt "$inst"; }; then
|
||||||
|
echo "INOPERANTE {{ nom }} candidat=$cand epingle={{ version }}"
|
||||||
|
fi
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
true
|
||||||
|
args:
|
||||||
|
executable: /bin/bash
|
||||||
|
environment:
|
||||||
|
LC_ALL: C
|
||||||
|
register: paquets_tiers_ecarts
|
||||||
|
changed_when: false
|
||||||
|
check_mode: false
|
||||||
|
when: paquets_tiers_epingles | length > 0
|
||||||
|
|
||||||
|
- name: Refuser une épingle qui ne tient pas
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- paquets_tiers_ecarts.stdout_lines | default([]) | length == 0
|
||||||
|
fail_msg: >-
|
||||||
|
Épingle(s) de paquets-tiers.yml qui ne tiennent pas sur {{ inventory_hostname }} :
|
||||||
|
{{ paquets_tiers_ecarts.stdout_lines | default([]) | join(' ; ') }}.
|
||||||
|
PLUS_RECENT : relever l'épingle sur la version installée (puis `make cacher-paquets`).
|
||||||
|
INOPERANTE : la version épinglée n'est pas dans l'index du dépôt — en choisir une qui y est.
|
||||||
|
when: paquets_tiers_epingles | length > 0
|
||||||
|
|
@ -2,6 +2,10 @@
|
||||||
# DÉGRADER, JAMAIS DEVINER. Sans cache sur le contrôleur, ce rôle ne fait rien et le DIT :
|
# DÉGRADER, JAMAIS DEVINER. Sans cache sur le contrôleur, ce rôle ne fait rien et le DIT :
|
||||||
# l'appelant installera depuis le dépôt distant, comme avant. On ne casse pas un
|
# l'appelant installera depuis le dépôt distant, comme avant. On ne casse pas un
|
||||||
# déploiement connecté parce qu'un cache manque.
|
# déploiement connecté parce qu'un cache manque.
|
||||||
|
# LES ÉPINGLES D'ABORD — voir `epingles.yml`, que le socle appelle aussi pour TOUTES.
|
||||||
|
- name: Épingler les paquets demandés (préférences apt)
|
||||||
|
ansible.builtin.include_tasks: epingles.yml
|
||||||
|
|
||||||
- name: Lire le manifeste du cache du contrôleur
|
- name: Lire le manifeste du cache du contrôleur
|
||||||
ansible.builtin.slurp:
|
ansible.builtin.slurp:
|
||||||
src: "{{ paquets_tiers_cache }}/MANIFESTE"
|
src: "{{ paquets_tiers_cache }}/MANIFESTE"
|
||||||
|
|
@ -65,6 +69,13 @@
|
||||||
loop_control:
|
loop_control:
|
||||||
label: "{{ item.key }}"
|
label: "{{ item.key }}"
|
||||||
|
|
||||||
|
# GARDER LES FICHIERS DE CONFIGURATION QUE LES RÔLES ONT MODIFIÉS (2026-10-03). Tant que ce
|
||||||
|
# rôle ne faisait que des PREMIÈRES installations, aucun fichier n'avait été touché. Depuis
|
||||||
|
# que les épingles montent, il MET À JOUR : sur `site-dns-01`, `alloy` 1.19.2 -> 1.20.1 a
|
||||||
|
# demandé quoi faire de `/etc/default/alloy` (le port y est imposé par `client_journal`),
|
||||||
|
# sans personne pour répondre — « fin de fichier sur l'entrée standard », paquet déballé et
|
||||||
|
# NON configuré. Mêmes options que le socle (`common_packages`) : garder la version locale.
|
||||||
|
#
|
||||||
# EN UN SEUL APPEL, ET C'EST LE POINT. `apt` sait résoudre un ENSEMBLE de fichiers locaux
|
# EN UN SEUL APPEL, ET C'EST LE POINT. `apt` sait résoudre un ENSEMBLE de fichiers locaux
|
||||||
# — l'un dépendant de l'autre — alors qu'une installation paquet par paquet échouerait sur
|
# — l'un dépendant de l'autre — alors qu'une installation paquet par paquet échouerait sur
|
||||||
# l'ordre. Icinga en apporte dix-sept qui se dépendent mutuellement : les poser un à un
|
# l'ordre. Icinga en apporte dix-sept qui se dépendent mutuellement : les poser un à un
|
||||||
|
|
@ -74,12 +85,16 @@
|
||||||
- name: Installer les paquets tiers depuis le cache
|
- name: Installer les paquets tiers depuis le cache
|
||||||
ansible.builtin.command:
|
ansible.builtin.command:
|
||||||
argv: >-
|
argv: >-
|
||||||
{{ ["apt-get", "install", "-y", "--no-install-recommends"]
|
{{ ["apt-get", "install", "-y", "--no-install-recommends",
|
||||||
|
"-o", "Dpkg::Options::=--force-confdef", "-o", "Dpkg::Options::=--force-confold"]
|
||||||
+ (paquets_tiers_disponibles.values()
|
+ (paquets_tiers_disponibles.values()
|
||||||
| map('regex_replace', '^', paquets_tiers_dest ~ '/') | list) }}
|
| map('regex_replace', '^', paquets_tiers_dest ~ '/') | list) }}
|
||||||
environment:
|
environment:
|
||||||
DEBIAN_FRONTEND: noninteractive
|
DEBIAN_FRONTEND: noninteractive
|
||||||
register: paquets_tiers_pose
|
register: paquets_tiers_pose
|
||||||
changed_when: "'0 nouvellement installés' not in paquets_tiers_pose.stdout
|
# UNE MISE À JOUR EST UN CHANGEMENT (2026-10-03). La garde ne cherchait que « 0 nouvellement
|
||||||
and '0 newly installed' not in paquets_tiers_pose.stdout"
|
# installés », qu'apt écrit AUSSI quand il met à jour (« 1 mis à jour, 0 nouvellement
|
||||||
|
# installés ») : `alloy` 1.19.2 -> 1.20.1 sur `site-cache-01` s'est rapporté « ok ».
|
||||||
|
changed_when: "'0 mis à jour, 0 nouvellement installés' not in paquets_tiers_pose.stdout
|
||||||
|
and '0 upgraded, 0 newly installed' not in paquets_tiers_pose.stdout"
|
||||||
when: paquets_tiers_disponibles | length > 0
|
when: paquets_tiers_disponibles | length > 0
|
||||||
|
|
|
||||||
|
|
@ -172,5 +172,7 @@ serveur_artefacts_directs:
|
||||||
url: "{{ serveur_keycloak_signature_url }}"
|
url: "{{ serveur_keycloak_signature_url }}"
|
||||||
- nom: "nextcloud-{{ serveur_nextcloud_version }}.tar.bz2"
|
- nom: "nextcloud-{{ serveur_nextcloud_version }}.tar.bz2"
|
||||||
url: "{{ serveur_nextcloud_url }}"
|
url: "{{ serveur_nextcloud_url }}"
|
||||||
|
- nom: "nextcloud-{{ serveur_nextcloud_version }}.tar.bz2.asc"
|
||||||
|
url: "{{ serveur_nextcloud_signature_url }}"
|
||||||
- nom: "oauth2-proxy-{{ serveur_oauth2_proxy_version }}.tar.gz"
|
- nom: "oauth2-proxy-{{ serveur_oauth2_proxy_version }}.tar.gz"
|
||||||
url: "{{ serveur_oauth2_proxy_url }}"
|
url: "{{ serveur_oauth2_proxy_url }}"
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,12 @@
|
||||||
# Supervision derivee du role. Voir docs/supervision-conception.md.
|
# Supervision derivee du role. Voir docs/supervision-conception.md.
|
||||||
sondes:
|
sondes:
|
||||||
- nom: cache-apt
|
- nom: cache-apt
|
||||||
|
metier: paquets
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
raison: Le cache d'artefacts repond-il ? S'il se tait, TOUT apt de l'ecosysteme echoue — on agit dans
|
raison: Le cache d'artefacts repond-il ? S'il se tait, TOUT apt de l'ecosysteme echoue — on agit dans
|
||||||
la minute.
|
la minute.
|
||||||
- nom: cache-apt-volume
|
- nom: cache-apt-volume
|
||||||
|
metier: paquets
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
raison: 'Reste-t-il de la place au cache ? Un cache plein cesse d''ecrire sans se plaindre, et l''echec
|
raison: 'Reste-t-il de la place au cache ? Un cache plein cesse d''ecrire sans se plaindre, et l''echec
|
||||||
apparait chez les clients. Verdict distinct de la disponibilite : il appelle un billet, pas un reveil.'
|
apparait chez les clients. Verdict distinct de la disponibilite : il appelle un billet, pas un reveil.'
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
# peremption. Le silence alerte autant que l'echec.
|
# peremption. Le silence alerte autant que l'echec.
|
||||||
sondes:
|
sondes:
|
||||||
- nom: depot
|
- nom: depot
|
||||||
|
metier: donnees
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
raison: 'Le depot accepte-t-il encore une ecriture ? Il ne juge pas le contenu — chiffre
|
raison: 'Le depot accepte-t-il encore une ecriture ? Il ne juge pas le contenu — chiffre
|
||||||
cote client, il ne peut pas l''ouvrir. Il constate que l''endroit prend encore : en
|
cote client, il ne peut pas l''ouvrir. Il constate que l''endroit prend encore : en
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
# peremption. Le silence alerte autant que l'echec.
|
# peremption. Le silence alerte autant que l'echec.
|
||||||
sondes:
|
sondes:
|
||||||
- nom: depot-locataires
|
- nom: depot-locataires
|
||||||
|
metier: sauvegardes_locataires
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
raison: 'Les depots des locataires sont-ils toujours etanches, et reste-t-il de la
|
raison: 'Les depots des locataires sont-ils toujours etanches, et reste-t-il de la
|
||||||
place devant eux ? Un mode qui glisse laisse un locataire lire ou effacer les
|
place devant eux ? Un mode qui glisse laisse un locataire lire ou effacer les
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
# peremption. Le silence alerte autant que l'echec.
|
# peremption. Le silence alerte autant que l'echec.
|
||||||
sondes:
|
sondes:
|
||||||
- nom: cache-site-racine
|
- nom: cache-site-racine
|
||||||
|
metier: paquets
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
raison: 'Ce cache est-il toujours la RACINE de la chaine, et peut-il encore remplir ?
|
raison: 'Ce cache est-il toujours la RACINE de la chaine, et peut-il encore remplir ?
|
||||||
Un cache chaine sur lui-meme ou coupe de Debian repond parfaitement — il sert ce
|
Un cache chaine sur lui-meme ou coupe de Debian repond parfaitement — il sert ce
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
# peremption. Le silence alerte autant que l'echec.
|
# peremption. Le silence alerte autant que l'echec.
|
||||||
sondes:
|
sondes:
|
||||||
- nom: edition
|
- nom: edition
|
||||||
|
metier: edition
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
raison: 'L''edition en ligne repond-elle ? `/hosting/discovery` est l''adresse que Nextcloud
|
raison: 'L''edition en ligne repond-elle ? `/hosting/discovery` est l''adresse que Nextcloud
|
||||||
interroge lui-meme pour savoir quels documents Collabora sait ouvrir. Sans elle, le
|
interroge lui-meme pour savoir quels documents Collabora sait ouvrir. Sans elle, le
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,14 @@
|
||||||
# correctif publie en exposition acceptee.
|
# correctif publie en exposition acceptee.
|
||||||
sondes:
|
sondes:
|
||||||
- nom: correctifs
|
- nom: correctifs
|
||||||
|
metier: socle
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
raison: >-
|
raison: >-
|
||||||
Des correctifs de securite attendent-ils d'etre appliques, et depuis quand ?
|
Des correctifs de securite attendent-ils d'etre appliques, et depuis quand ?
|
||||||
Set-OPS desarme les mises a jour automatiques et les applique au deploiement :
|
Set-OPS desarme les mises a jour automatiques et les applique au deploiement :
|
||||||
sans cette mesure, rien ne dit quand le geste est du.
|
sans cette mesure, rien ne dit quand le geste est du.
|
||||||
- nom: horloge
|
- nom: horloge
|
||||||
|
metier: socle
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
raison: >-
|
raison: >-
|
||||||
L'heure est-elle disciplinee, et par la SOURCE DECLAREE ? Une machine peut etre
|
L'heure est-elle disciplinee, et par la SOURCE DECLAREE ? Une machine peut etre
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
# Supervision derivee du role. Voir docs/supervision-conception.md.
|
# Supervision derivee du role. Voir docs/supervision-conception.md.
|
||||||
sondes:
|
sondes:
|
||||||
- nom: zones-publiques
|
- nom: zones-publiques
|
||||||
|
metier: noms_publics
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
raison: 'Le serveur public sert-il TOUTES les zones de ses locataires ? Un secondaire qui n''a jamais
|
raison: 'Le serveur public sert-il TOUTES les zones de ses locataires ? Un secondaire qui n''a jamais
|
||||||
recu une zone repond REFUSED : pour Internet, ce domaine n''a plus de serveur de noms, pendant que le
|
recu une zone repond REFUSED : pour Internet, ce domaine n''a plus de serveur de noms, pendant que le
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
# Supervision derivee du role. Voir docs/supervision-conception.md.
|
# Supervision derivee du role. Voir docs/supervision-conception.md.
|
||||||
sondes:
|
sondes:
|
||||||
- nom: boites
|
- nom: boites
|
||||||
|
metier: courriel
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
raison: 'Dovecot repond-il, et sait-il encore RESOUDRE un utilisateur dans l''annuaire ? Un Dovecot
|
raison: 'Dovecot repond-il, et sait-il encore RESOUDRE un utilisateur dans l''annuaire ? Un Dovecot
|
||||||
dont le lien LDAP est tombe accepte les connexions et refuse toutes les authentifications : le service
|
dont le lien LDAP est tombe accepte les connexions et refuse toutes les authentifications : le service
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,41 @@
|
||||||
group: "{{ serveur_dovecot_vmail_utilisateur }}"
|
group: "{{ serveur_dovecot_vmail_utilisateur }}"
|
||||||
mode: "0750"
|
mode: "0750"
|
||||||
|
|
||||||
|
# LES BOITES D'UNE INCARNATION PRECEDENTE (2026-09-30). Remises tant que le repertoire est
|
||||||
|
# vide — avant que la premiere livraison n'y cree quoi que ce soit.
|
||||||
|
- name: Restauration — les boites sont-elles vierges ?
|
||||||
|
ansible.builtin.find:
|
||||||
|
paths: "{{ serveur_dovecot_vmail_base }}"
|
||||||
|
file_type: any
|
||||||
|
register: serveur_dovecot_vmail_initial
|
||||||
|
|
||||||
|
- name: Restauration — serveur_dovecot d'une incarnation precedente ?
|
||||||
|
ansible.builtin.include_role:
|
||||||
|
name: client_backup
|
||||||
|
tasks_from: restaurer.yml
|
||||||
|
vars:
|
||||||
|
client_backup_restaurer_jeu: serveur_dovecot
|
||||||
|
client_backup_restaurer_vierge: "{{ serveur_dovecot_vmail_initial.matched | default(1) == 0 }}"
|
||||||
|
|
||||||
|
- name: Restauration — remettre serveur_dovecot
|
||||||
|
changed_when: true
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv: >-
|
||||||
|
{{ ['/usr/local/sbin/setops-restaurer', 'fichiers',
|
||||||
|
'--instantane', client_backup_restauration.instantane]
|
||||||
|
+ ['--proprietaire', serveur_dovecot_vmail_utilisateur + ':' + serveur_dovecot_vmail_utilisateur]
|
||||||
|
+ client_backup_restauration.chemins }}
|
||||||
|
when: client_backup_restauration.etat == 'a_restaurer'
|
||||||
|
|
||||||
|
- name: Restauration — acter serveur_dovecot
|
||||||
|
ansible.builtin.include_role:
|
||||||
|
name: client_backup
|
||||||
|
tasks_from: acter.yml
|
||||||
|
vars:
|
||||||
|
client_backup_acter_etat: restaure
|
||||||
|
when: client_backup_restauration.etat == 'a_restaurer'
|
||||||
|
|
||||||
|
|
||||||
# --- Pont de certificat step_ca (déposé par client_pki) ---
|
# --- Pont de certificat step_ca (déposé par client_pki) ---
|
||||||
- name: TLS — créer le répertoire des certificats
|
- name: TLS — créer le répertoire des certificats
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
# laisse la meme trace qu'un audit en bonne sante pour qui ne regarde que `systemctl`.
|
# laisse la meme trace qu'un audit en bonne sante pour qui ne regarde que `systemctl`.
|
||||||
sondes:
|
sondes:
|
||||||
- nom: audit
|
- nom: audit
|
||||||
|
metier: socle
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
# Posee par `auditd` seulement si `auditd_enabled` : Icinga ne l'attend qu'a ces
|
# Posee par `auditd` seulement si `auditd_enabled` : Icinga ne l'attend qu'a ces
|
||||||
# conditions (voir `setops-sondes.conf.j2`, 2026-09-28).
|
# conditions (voir `setops-sondes.conf.j2`, 2026-09-28).
|
||||||
|
|
@ -27,3 +28,15 @@ sondes:
|
||||||
La piste d'audit s'ecrit-elle vraiment ? Des regles armees dans le noyau ne
|
La piste d'audit s'ecrit-elle vraiment ? Des regles armees dans le noyau ne
|
||||||
prouvent pas qu'un demon collecte : le 2026-08-30, quinze machines ont eu les
|
prouvent pas qu'un demon collecte : le 2026-08-30, quinze machines ont eu les
|
||||||
deux apparences de la sante sans qu'une seule ligne soit ecrite.
|
deux apparences de la sante sans qu'une seule ligne soit ecrite.
|
||||||
|
- nom: connectivite
|
||||||
|
metier: socle
|
||||||
|
ttl: 180
|
||||||
|
# Attendue a la minute, et non au quart d'heure : c'est tout son interet. Voir le mode
|
||||||
|
# `minute` du porteur (`client_sante`).
|
||||||
|
fraicheur: 180
|
||||||
|
raison: >-
|
||||||
|
Chaque flux que le registre promet a cette machine passe-t-il, maintenant ? Un pare-feu
|
||||||
|
(Proxmox ou nftables) qui coupe un flux declare ne casse rien de visible tout de suite :
|
||||||
|
la sauvegarde echoue cette nuit, le certificat ne se renouvelle pas demain. Teste chaque
|
||||||
|
minute depuis la machine qui doit joindre, avec la cause (rejete, delai, personne
|
||||||
|
n'ecoute) — et les connexions entrantes que les regles declarees ne couvrent pas.
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
# peremption. Le silence alerte autant que l'echec.
|
# peremption. Le silence alerte autant que l'echec.
|
||||||
sondes:
|
sondes:
|
||||||
- nom: genome-servi
|
- nom: genome-servi
|
||||||
|
metier: forge
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
raison: 'Cette forge a-t-elle encore un genome a servir ? Une forge vide repond
|
raison: 'Cette forge a-t-elle encore un genome a servir ? Une forge vide repond
|
||||||
parfaitement : son API marche, son Git est lisible, elle n''a simplement plus rien
|
parfaitement : son API marche, son Git est lisible, elle n''a simplement plus rien
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
# Version Forgejo : verifier/ajuster sur https://forgejo.org/download/
|
# Version Forgejo : verifier/ajuster sur https://forgejo.org/download/
|
||||||
serveur_forgejo_version: "16.0.2"
|
serveur_forgejo_version: "16.0.5" # 2026-10-03 : trois corrections apres 16.0.2, meme branche
|
||||||
serveur_forgejo_url: >-
|
serveur_forgejo_url: >-
|
||||||
https://codeberg.org/forgejo/forgejo/releases/download/v{{ serveur_forgejo_version }}/forgejo-{{ serveur_forgejo_version }}-linux-amd64
|
https://codeberg.org/forgejo/forgejo/releases/download/v{{ serveur_forgejo_version }}/forgejo-{{ serveur_forgejo_version }}-linux-amd64
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,17 @@ flux:
|
||||||
# dépend ». Le vocabulaire des flux n'a pas de mot pour l'indécision, et c'est tant
|
# dépend ». Le vocabulaire des flux n'a pas de mot pour l'indécision, et c'est tant
|
||||||
# mieux : un flux se lit pour savoir ce qui circule et comment. Une valeur ambiguë
|
# mieux : un flux se lit pour savoir ce qui circule et comment. Une valeur ambiguë
|
||||||
# rendrait l'audit impossible sans aller lire le déploiement.
|
# rendrait l'audit impossible sans aller lire le déploiement.
|
||||||
|
#
|
||||||
|
# ET LA SITUATION SE LIT DANS LE REGLAGE (2026-09-30) : la premiere seulement si la forge ne
|
||||||
|
# sert pas son propre TLS. Au site (`serveur_forgejo_tls: true`), elle ecoute 443 et rien
|
||||||
|
# sur 3000 ; la regle restait posee parce que le site a un edge — et la sonde
|
||||||
|
# `connectivite` la montrait coupee chaque minute.
|
||||||
- sens: ingress
|
- sens: ingress
|
||||||
port: 3000
|
port: 3000
|
||||||
protocole: tcp
|
protocole: tcp
|
||||||
pair: edge
|
pair: edge
|
||||||
chiffrement: clair
|
chiffrement: clair
|
||||||
|
seulement_si: {variable: serveur_forgejo_tls, egal: false}
|
||||||
raison: >-
|
raison: >-
|
||||||
Interface web + Git HTTP derrière un edge : le nginx termine le TLS et parle en
|
Interface web + Git HTTP derrière un edge : le nginx termine le TLS et parle en
|
||||||
clair à la forge. C'est le cas de tout tenant.
|
clair à la forge. C'est le cas de tout tenant.
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
# Supervision derivee du role. Voir docs/supervision-conception.md.
|
# Supervision derivee du role. Voir docs/supervision-conception.md.
|
||||||
sondes:
|
sondes:
|
||||||
- nom: forge
|
- nom: forge
|
||||||
|
metier: forge
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
raison: La forge repond-elle a son API, et son depot Git est-il lisible ? Une forge qui sert sa page
|
raison: La forge repond-elle a son API, et son depot Git est-il lisible ? Une forge qui sert sa page
|
||||||
d'accueil mais ne peut plus lire ses depots est une panne complete pour tout ce qui clone — et elle
|
d'accueil mais ne peut plus lire ses depots est une panne complete pour tout ce qui clone — et elle
|
||||||
|
|
|
||||||
|
|
@ -272,6 +272,57 @@
|
||||||
dest: "{{ serveur_forgejo_binaire }}"
|
dest: "{{ serveur_forgejo_binaire }}"
|
||||||
state: link
|
state: link
|
||||||
notify: Redemarrer forgejo
|
notify: Redemarrer forgejo
|
||||||
|
# En simulation, une NOUVELLE version n'a pas ete copiee : le lien n'aurait pas de cible
|
||||||
|
# (2026-10-03, premiere montee simulee). Une version deja posee, elle, se simule.
|
||||||
|
when: not (ansible_check_mode and not serveur_forgejo_binaire_present.stat.exists)
|
||||||
|
|
||||||
|
# LES DEPOTS D'UNE INCARNATION PRECEDENTE (2026-09-30). Avant que le role ne cree
|
||||||
|
# l'arborescence : ensuite, le repertoire ne serait plus vierge. La base, elle, a deja
|
||||||
|
# ete remise par `serveur_postgresql` (ou vit dans ces fichiers, en SQLite). La
|
||||||
|
# configuration revient avec le secret JWT que le role relit plus bas.
|
||||||
|
- name: Restauration — les donnees de Forgejo sont-elles vierges ?
|
||||||
|
ansible.builtin.find:
|
||||||
|
paths: "{{ serveur_forgejo_data }}"
|
||||||
|
file_type: any
|
||||||
|
register: serveur_forgejo_data_initial
|
||||||
|
|
||||||
|
- name: Restauration — serveur_forgejo d'une incarnation precedente ?
|
||||||
|
ansible.builtin.include_role:
|
||||||
|
name: client_backup
|
||||||
|
tasks_from: restaurer.yml
|
||||||
|
vars:
|
||||||
|
client_backup_restaurer_jeu: serveur_forgejo
|
||||||
|
client_backup_restaurer_vierge: "{{ serveur_forgejo_data_initial.matched | default(1) == 0 }}"
|
||||||
|
|
||||||
|
- name: Restauration — remettre serveur_forgejo
|
||||||
|
changed_when: true
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv: >-
|
||||||
|
{{ ['/usr/local/sbin/setops-restaurer', 'fichiers',
|
||||||
|
'--instantane', client_backup_restauration.instantane]
|
||||||
|
+ client_backup_restauration.chemins }}
|
||||||
|
when: client_backup_restauration.etat == 'a_restaurer'
|
||||||
|
|
||||||
|
# restic rend les proprietaires NUMERIQUES ; `git` n'a pas forcement le meme uid que sur
|
||||||
|
# la machine d'avant.
|
||||||
|
- name: Restauration — rendre les donnees au service
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item.chemin }}"
|
||||||
|
owner: "{{ item.proprietaire }}"
|
||||||
|
group: "{{ serveur_forgejo_utilisateur }}"
|
||||||
|
recurse: true
|
||||||
|
loop:
|
||||||
|
- { chemin: "{{ serveur_forgejo_data }}", proprietaire: "{{ serveur_forgejo_utilisateur }}" }
|
||||||
|
- { chemin: "{{ serveur_forgejo_config_dir }}", proprietaire: root }
|
||||||
|
when: client_backup_restauration.etat == 'a_restaurer'
|
||||||
|
|
||||||
|
- name: Restauration — acter serveur_forgejo
|
||||||
|
ansible.builtin.include_role:
|
||||||
|
name: client_backup
|
||||||
|
tasks_from: acter.yml
|
||||||
|
vars:
|
||||||
|
client_backup_acter_etat: restaure
|
||||||
|
when: client_backup_restauration.etat == 'a_restaurer'
|
||||||
|
|
||||||
- name: Creer les repertoires de donnees
|
- name: Creer les repertoires de donnees
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
# Supervision derivee du role. Voir docs/supervision-conception.md.
|
# Supervision derivee du role. Voir docs/supervision-conception.md.
|
||||||
sondes:
|
sondes:
|
||||||
- nom: tableaux
|
- nom: tableaux
|
||||||
|
metier: supervision
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
raison: 'Grafana repond-il, sa base tient-elle, et ses SOURCES DE DONNEES aussi ? Un Grafana
|
raison: 'Grafana repond-il, sa base tient-elle, et ses SOURCES DE DONNEES aussi ? Un Grafana
|
||||||
dont la base est tombee sert encore sa page de connexion ; un Grafana dont la source Loki
|
dont la base est tombee sert encore sa page de connexion ; un Grafana dont la source Loki
|
||||||
|
|
|
||||||
|
|
@ -335,6 +335,21 @@
|
||||||
when: setops_materiel is defined
|
when: setops_materiel is defined
|
||||||
notify: Redemarrer grafana
|
notify: Redemarrer grafana
|
||||||
|
|
||||||
|
# --- LA SYNTHESE (2026-10-04) — la porte d'entree, au site comme chez un locataire ------
|
||||||
|
#
|
||||||
|
# Le meme gabarit partout : il ajoute les hyperviseurs quand `setops_materiel` existe (au
|
||||||
|
# site), et ne voit que les machines de l'ecosysteme sinon. Pas de prefixe `setops-role-` :
|
||||||
|
# la moisson des tableaux orphelins ne doit pas le voir.
|
||||||
|
- name: Assembler le tableau de synthèse
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: tableau-synthese.json.j2
|
||||||
|
dest: "{{ serveur_grafana_dashboards_dir }}/setops-synthese.json"
|
||||||
|
owner: root
|
||||||
|
group: grafana
|
||||||
|
mode: "0640"
|
||||||
|
validate: "python3 -c \"import json,sys; json.load(open(sys.argv[1]))\" %s"
|
||||||
|
notify: Redemarrer grafana
|
||||||
|
|
||||||
# --- UN ROLE QU'ON RETIRE DOIT POUVOIR DEFAIRE CE QU'IL A FAIT ------------------------
|
# --- UN ROLE QU'ON RETIRE DOIT POUVOIR DEFAIRE CE QU'IL A FAIT ------------------------
|
||||||
#
|
#
|
||||||
# Meme lecon que les sondes orphelines de `client_sante`, et elle s'est deja payee ici :
|
# Meme lecon que les sondes orphelines de `client_sante`, et elle s'est deja payee ici :
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@ Environment=GF_SERVER_DOMAIN={{ serveur_grafana_hostname }}
|
||||||
Environment=GF_SERVER_ROOT_URL={{ serveur_grafana_root_url }}
|
Environment=GF_SERVER_ROOT_URL={{ serveur_grafana_root_url }}
|
||||||
Environment=GF_SECURITY_ADMIN_PASSWORD={{ serveur_grafana_admin_password }}
|
Environment=GF_SECURITY_ADMIN_PASSWORD={{ serveur_grafana_admin_password }}
|
||||||
Environment=GF_USERS_ALLOW_SIGN_UP=false
|
Environment=GF_USERS_ALLOW_SIGN_UP=false
|
||||||
|
{# LA SYNTHESE COMME PAGE D'ACCUEIL (2026-10-04) : a la connexion, la question « faut-il
|
||||||
|
agir ? » plutot que la liste des tableaux. Le fichier est pose par ce role (tableau de
|
||||||
|
synthese) ; sans lui, Grafana revient a son accueil par defaut, sans erreur. #}
|
||||||
|
Environment=GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH={{ serveur_grafana_dashboards_dir }}/setops-synthese.json
|
||||||
{# Le formulaire local est offert, ou il ne l'est pas — et le gabarit le DIT dans
|
{# Le formulaire local est offert, ou il ne l'est pas — et le gabarit le DIT dans
|
||||||
les deux cas. Cette ligne vivait sous le `if` du SSO : SSO eteint, elle
|
les deux cas. Cette ligne vivait sous le `if` du SSO : SSO eteint, elle
|
||||||
disparaissait, et c'est le defaut de Grafana qui decidait en silence. Un
|
disparaissait, et c'est le defaut de Grafana qui decidait en silence. Un
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,30 @@
|
||||||
{"color": "red", "value": f.crit}]} | tojson }}
|
{"color": "red", "value": f.crit}]} | tojson }}
|
||||||
{%- endmacro -%}
|
{%- endmacro -%}
|
||||||
|
|
||||||
|
{#- LES PHRASES DES PANNEAUX (2026-10-04). Trois temps, toujours les memes : ce que ca
|
||||||
|
mesure (`raison`), ou sont les seuils, et QUOI FAIRE quand la couleur change (`action`,
|
||||||
|
dans le catalogue). Grafana les montre au survol du titre (icone i), en Markdown. -#}
|
||||||
|
{%- macro desc_temp(f) -%}
|
||||||
|
{{ f.raison ~ "\n\n**Seuils** : orange à " ~ f.avert ~ " °C, rouge à " ~ f.crit ~ " °C — le verdict Icinga est lissé sur " ~ (f.jugee_sur | replace("m", " min")) ~ " : un pic d'une seconde n'est pas une panne.\n\n**Si c'est orange ou rouge** : " ~ f.action }}
|
||||||
|
{%- endmacro -%}
|
||||||
|
{%- macro desc_ctrl(c) -%}
|
||||||
|
{{ c.raison ~ "\n\n**Si la tuile n'affiche pas 0** : " ~ c.action }}
|
||||||
|
{%- endmacro -%}
|
||||||
|
|
||||||
|
{#- ---- MODE D'EMPLOI ---------------------------------------------------------------- #}
|
||||||
|
{%- set y.id = y.id + 1 -%}
|
||||||
|
{%- set _ = panneaux.append({
|
||||||
|
"type": "text", "id": y.id, "title": "",
|
||||||
|
"gridPos": {"h": 4, "w": 24, "x": 0, "y": y.v},
|
||||||
|
"options": {"mode": "markdown", "content":
|
||||||
|
"**Comment lire ce tableau.** Chaque rangée répond à une question.\n\n"
|
||||||
|
~ "- **État en direct** — y a-t-il quelque chose à faire maintenant ? Vert : rien. Orange : à planifier. Rouge : à traiter.\n"
|
||||||
|
~ "- **Températures** — depuis quand, et sur quelle machine ? Les pointillés sont les seuils.\n"
|
||||||
|
~ "- **Ventilateurs et puissance** — une chaleur vient-elle de la charge ou du refroidissement ?\n"
|
||||||
|
~ "- **Stockage** — quels disques vieillissent, et lesquels se dégradent ?\n\n"
|
||||||
|
~ "Survoler le **titre** d'un panneau (icône ⓘ) : il dit ce qu'il mesure, pourquoi cela compte, et **quoi faire** s'il change de couleur. Seuils identiques à ceux du service Icinga « materiel »."}}) -%}
|
||||||
|
{%- set y.v = y.v + 4 -%}
|
||||||
|
|
||||||
{#- ---- RANGEE : ETAT EN DIRECT ------------------------------------------------------ #}
|
{#- ---- RANGEE : ETAT EN DIRECT ------------------------------------------------------ #}
|
||||||
{%- set y.id = y.id + 1 -%}
|
{%- set y.id = y.id + 1 -%}
|
||||||
{%- set _ = panneaux.append({"type": "row", "id": y.id, "title": "État en direct", "collapsed": false,
|
{%- set _ = panneaux.append({"type": "row", "id": y.id, "title": "État en direct", "collapsed": false,
|
||||||
|
|
@ -32,7 +56,7 @@
|
||||||
{%- set _ = panneaux.append({
|
{%- set _ = panneaux.append({
|
||||||
"type": "stat", "id": y.id,
|
"type": "stat", "id": y.id,
|
||||||
"title": f.titre ~ " — maximum",
|
"title": f.titre ~ " — maximum",
|
||||||
"description": f.raison,
|
"description": desc_temp(f),
|
||||||
"datasource": {"type": "prometheus", "uid": "${ds_prom}"},
|
"datasource": {"type": "prometheus", "uid": "${ds_prom}"},
|
||||||
"gridPos": {"h": 5, "w": 6, "x": loop.index0 * 6, "y": y.v},
|
"gridPos": {"h": 5, "w": 6, "x": loop.index0 * 6, "y": y.v},
|
||||||
"fieldConfig": {"defaults": {"unit": "celsius", "decimals": 0,
|
"fieldConfig": {"defaults": {"unit": "celsius", "decimals": 0,
|
||||||
|
|
@ -53,7 +77,7 @@
|
||||||
{%- set _ = panneaux.append({
|
{%- set _ = panneaux.append({
|
||||||
"type": "stat", "id": y.id,
|
"type": "stat", "id": y.id,
|
||||||
"title": c.titre,
|
"title": c.titre,
|
||||||
"description": c.raison,
|
"description": desc_ctrl(c),
|
||||||
"datasource": {"type": "prometheus", "uid": "${ds_prom}"},
|
"datasource": {"type": "prometheus", "uid": "${ds_prom}"},
|
||||||
"gridPos": {"h": 4, "w": largeur, "x": (loop.index0 * largeur) % 24, "y": y.v + ((loop.index0 * largeur) // 24) * 4},
|
"gridPos": {"h": 4, "w": largeur, "x": (loop.index0 * largeur) % 24, "y": y.v + ((loop.index0 * largeur) // 24) * 4},
|
||||||
"fieldConfig": {"defaults": {"unit": "none", "decimals": 0,
|
"fieldConfig": {"defaults": {"unit": "none", "decimals": 0,
|
||||||
|
|
@ -80,7 +104,7 @@
|
||||||
{%- set _ = panneaux.append({
|
{%- set _ = panneaux.append({
|
||||||
"type": "timeseries", "id": y.id,
|
"type": "timeseries", "id": y.id,
|
||||||
"title": f.titre,
|
"title": f.titre,
|
||||||
"description": f.raison ~ " — Avertissement " ~ f.avert ~ " °C, critique " ~ f.crit ~ " °C (verdict Icinga lisse sur " ~ f.jugee_sur ~ ").",
|
"description": desc_temp(f),
|
||||||
"datasource": {"type": "prometheus", "uid": "${ds_prom}"},
|
"datasource": {"type": "prometheus", "uid": "${ds_prom}"},
|
||||||
"gridPos": {"h": 9, "w": 12, "x": 0 if loop.index0 % 2 == 0 else 12, "y": y.v + (loop.index0 // 2) * 9},
|
"gridPos": {"h": 9, "w": 12, "x": 0 if loop.index0 % 2 == 0 else 12, "y": y.v + (loop.index0 // 2) * 9},
|
||||||
"fieldConfig": {"defaults": {
|
"fieldConfig": {"defaults": {
|
||||||
|
|
@ -107,7 +131,7 @@
|
||||||
{%- set y.id = y.id + 1 -%}
|
{%- set y.id = y.id + 1 -%}
|
||||||
{%- set _ = panneaux.append({
|
{%- set _ = panneaux.append({
|
||||||
"type": "timeseries", "id": y.id, "title": "Ventilateurs",
|
"type": "timeseries", "id": y.id, "title": "Ventilateurs",
|
||||||
"description": "Seuls les connecteurs qui ont tourne dans les dernieres 24 h : un connecteur vide lit 0 depuis toujours et n'apprend rien.",
|
"description": "Vitesse de chaque ventilateur, en tours par minute. Seuls les connecteurs qui ont tourné dans les dernières 24 h sont tracés : un connecteur vide lit 0 depuis toujours et n'apprend rien.\n\n**À lire avec les températures** : une température qui monte pendant que les ventilateurs restent bas parle d'une courbe de ventilation trop douce ; une courbe qui tombe à 0, d'un ventilateur arrêté (tuile rouge plus haut).",
|
||||||
"datasource": {"type": "prometheus", "uid": "${ds_prom}"},
|
"datasource": {"type": "prometheus", "uid": "${ds_prom}"},
|
||||||
"gridPos": {"h": 9, "w": 12, "x": 0, "y": y.v},
|
"gridPos": {"h": 9, "w": 12, "x": 0, "y": y.v},
|
||||||
"fieldConfig": {"defaults": {"unit": "rotrpm", "decimals": 0, "color": {"mode": "palette-classic"},
|
"fieldConfig": {"defaults": {"unit": "rotrpm", "decimals": 0, "color": {"mode": "palette-classic"},
|
||||||
|
|
@ -121,7 +145,7 @@
|
||||||
{%- set y.id = y.id + 1 -%}
|
{%- set y.id = y.id + 1 -%}
|
||||||
{%- set _ = panneaux.append({
|
{%- set _ = panneaux.append({
|
||||||
"type": "timeseries", "id": y.id, "title": "Puissance du processeur (PPT)",
|
"type": "timeseries", "id": y.id, "title": "Puissance du processeur (PPT)",
|
||||||
"description": "Puissance du boitier processeur annoncee par le pilote amdgpu (APU integre). Elle suit la charge : une temperature qui monte SANS puissance qui monte parle du refroidissement.",
|
"description": "Puissance du processeur (PPT), annoncée par le pilote amdgpu de l'APU intégré. Ce n'est pas la consommation de la machine entière : aucune mesure ne la donne.\n\n**À lire avec les températures** : puissance et température qui montent ensemble, c'est la charge. Une température qui monte **sans** que la puissance monte, c'est le refroidissement : ventilateurs, poussière, pâte thermique.",
|
||||||
"datasource": {"type": "prometheus", "uid": "${ds_prom}"},
|
"datasource": {"type": "prometheus", "uid": "${ds_prom}"},
|
||||||
"gridPos": {"h": 9, "w": 12, "x": 12, "y": y.v},
|
"gridPos": {"h": 9, "w": 12, "x": 12, "y": y.v},
|
||||||
"fieldConfig": {"defaults": {"unit": "watt", "decimals": 1, "color": {"mode": "palette-classic"},
|
"fieldConfig": {"defaults": {"unit": "watt", "decimals": 1, "color": {"mode": "palette-classic"},
|
||||||
|
|
@ -142,7 +166,7 @@
|
||||||
{%- set y.id = y.id + 1 -%}
|
{%- set y.id = y.id + 1 -%}
|
||||||
{%- set _ = panneaux.append({
|
{%- set _ = panneaux.append({
|
||||||
"type": "bargauge", "id": y.id, "title": "Usure des NVMe",
|
"type": "bargauge", "id": y.id, "title": "Usure des NVMe",
|
||||||
"description": "Part de l'endurance annoncee deja consommee. Avertissement a 80 % : a commander, pas encore a remplacer.",
|
"description": "Part de l'endurance garantie déjà consommée par chaque NVMe, selon le disque lui-même.\n\n**Seuils** : orange à 80 %, rouge à 90 %.\n\n**Si c'est orange** : commander un remplaçant, rien ne presse encore. **Si c'est rouge** : planifier le remplacement ; pour un OSD Ceph, le sortir du cluster avant.",
|
||||||
"datasource": {"type": "prometheus", "uid": "${ds_prom}"},
|
"datasource": {"type": "prometheus", "uid": "${ds_prom}"},
|
||||||
"gridPos": {"h": 9, "w": 8, "x": 0, "y": y.v},
|
"gridPos": {"h": 9, "w": 8, "x": 0, "y": y.v},
|
||||||
"fieldConfig": {"defaults": {"unit": "percentunit", "min": 0, "max": 1, "decimals": 0,
|
"fieldConfig": {"defaults": {"unit": "percentunit", "min": 0, "max": 1, "decimals": 0,
|
||||||
|
|
@ -156,7 +180,7 @@
|
||||||
{%- set y.id = y.id + 1 -%}
|
{%- set y.id = y.id + 1 -%}
|
||||||
{%- set _ = panneaux.append({
|
{%- set _ = panneaux.append({
|
||||||
"type": "bargauge", "id": y.id, "title": "Secteurs défaillants",
|
"type": "bargauge", "id": y.id, "title": "Secteurs défaillants",
|
||||||
"description": "En attente + irreparables + realloues, par disque SATA. Un compte stable se surveille ; un compte qui MONTE se remplace (controle « secteurs en progression »).",
|
"description": "Secteurs en attente, irréparables et réalloués, additionnés par disque SATA.\n\n**Un compte stable se surveille, un compte qui monte se remplace** : la tuile « secteurs en progression » passe au rouge dès qu'il augmente.\n\n**Si ce n'est pas 0** : prévoir le remplacement ; pour un OSD Ceph, le sortir du cluster (« out ») avant que la dégradation s'accélère.",
|
||||||
"datasource": {"type": "prometheus", "uid": "${ds_prom}"},
|
"datasource": {"type": "prometheus", "uid": "${ds_prom}"},
|
||||||
"gridPos": {"h": 9, "w": 8, "x": 8, "y": y.v},
|
"gridPos": {"h": 9, "w": 8, "x": 8, "y": y.v},
|
||||||
"fieldConfig": {"defaults": {"unit": "none", "min": 0, "decimals": 0,
|
"fieldConfig": {"defaults": {"unit": "none", "min": 0, "decimals": 0,
|
||||||
|
|
@ -170,7 +194,7 @@
|
||||||
{%- set y.id = y.id + 1 -%}
|
{%- set y.id = y.id + 1 -%}
|
||||||
{%- set _ = panneaux.append({
|
{%- set _ = panneaux.append({
|
||||||
"type": "bargauge", "id": y.id, "title": "Heures de fonctionnement",
|
"type": "bargauge", "id": y.id, "title": "Heures de fonctionnement",
|
||||||
"description": "Age reel des disques. Un disque mecanique au-dela de 40 000 heures (4,5 ans) entre dans la zone ou les pannes se multiplient.",
|
"description": "Âge réel de chaque disque, en heures sous tension.\n\n**Seuil** : orange au-delà de 40 000 heures (4,5 ans), quand un disque mécanique entre dans la zone où les pannes se multiplient.\n\n**Si c'est orange** : en prévoir le remplacement dans le budget, surtout s'il porte aussi des secteurs défaillants.",
|
||||||
"datasource": {"type": "prometheus", "uid": "${ds_prom}"},
|
"datasource": {"type": "prometheus", "uid": "${ds_prom}"},
|
||||||
"gridPos": {"h": 9, "w": 8, "x": 16, "y": y.v},
|
"gridPos": {"h": 9, "w": 8, "x": 16, "y": y.v},
|
||||||
"fieldConfig": {"defaults": {"unit": "h", "min": 0, "decimals": 0,
|
"fieldConfig": {"defaults": {"unit": "h", "min": 0, "decimals": 0,
|
||||||
|
|
@ -185,7 +209,7 @@
|
||||||
{{ {
|
{{ {
|
||||||
"uid": "setops-materiel",
|
"uid": "setops-materiel",
|
||||||
"title": "Set-OPS — Matériel",
|
"title": "Set-OPS — Matériel",
|
||||||
"description": "Hyperviseurs et frontiere : temperatures, ventilateurs, puissance, disques. Meme catalogue et memes seuils que le service Icinga « materiel » (scripts/materiel.py).",
|
"description": "Hyperviseurs et frontière : températures, ventilateurs, puissance, disques. Même catalogue et mêmes seuils que le service Icinga « materiel » (scripts/materiel.py).",
|
||||||
"tags": ["set-ops", "derive", "materiel", "fabric"],
|
"tags": ["set-ops", "derive", "materiel", "fabric"],
|
||||||
"timezone": "browser",
|
"timezone": "browser",
|
||||||
"refresh": "1m",
|
"refresh": "1m",
|
||||||
|
|
|
||||||
216
roles/serveur_grafana/templates/tableau-synthese.json.j2
Normal file
216
roles/serveur_grafana/templates/tableau-synthese.json.j2
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
{#- GENERE par Set-OPS (role serveur_grafana). Ne pas editer a la main.
|
||||||
|
|
||||||
|
LE TABLEAU DE SYNTHESE (2026-10-04) — la porte d'entree de l'observatoire.
|
||||||
|
|
||||||
|
Quatre rangees, quatre questions : faut-il agir ? reste-t-il de la marge ? que se
|
||||||
|
passe-t-il ? ou regarder ensuite ? Chaque panneau dit ce qu'il mesure, ou sont ses
|
||||||
|
seuils, et QUOI FAIRE quand sa couleur change — meme patron que le tableau Materiel.
|
||||||
|
|
||||||
|
LE MEME GABARIT AU SITE ET CHEZ UN LOCATAIRE. Au site, `setops_materiel` existe : la
|
||||||
|
synthese ajoute les hyperviseurs (tuile materiel, memoire et swap) et regarde leur
|
||||||
|
activite. Chez un locataire, elle ne voit que ses propres machines.
|
||||||
|
|
||||||
|
LE NOM DES MACHINES vient de `node_uname_info` (joint sur `instance`) : les locataires
|
||||||
|
n'ont pas l'etiquette `hote` du site, et une adresse IP ne parle a personne.
|
||||||
|
-#}
|
||||||
|
{%- set fabric = setops_materiel is defined -%}
|
||||||
|
{#- La tuile materiel rend un NIVEAU (0, 1, 2), pas un compte : `count(x) > 0` garde la
|
||||||
|
valeur du compte — trois secteurs en avertissement s'affichaient « 3 ». -#}
|
||||||
|
{%- set cat = setops_materiel | default({"temperatures": [], "controles": []}) -%}
|
||||||
|
{%- set nom = ' * on(instance) group_left(nodename) node_uname_info' -%}
|
||||||
|
{%- set ds = {"type": "prometheus", "uid": "${ds_prom}"} -%}
|
||||||
|
{%- set panneaux = [] -%}
|
||||||
|
{%- set y = namespace(v=0, id=0) -%}
|
||||||
|
|
||||||
|
{#- ---- MODE D'EMPLOI ---------------------------------------------------------------- #}
|
||||||
|
{%- set y.id = y.id + 1 -%}
|
||||||
|
{%- set _ = panneaux.append({
|
||||||
|
"type": "text", "id": y.id, "title": "",
|
||||||
|
"gridPos": {"h": 3, "w": 24, "x": 0, "y": y.v},
|
||||||
|
"options": {"mode": "markdown", "content":
|
||||||
|
"**Synthèse " ~ ("du site" if fabric else "de l'écosystème") ~ ".** Quatre rangées, quatre questions : **faut-il agir ?** — **reste-t-il de la marge ?** — **que se passe-t-il ?** — **où regarder ensuite ?** "
|
||||||
|
~ "Vert : rien à faire. Orange : à planifier. Rouge : à traiter. Survoler le titre d'un panneau (ⓘ) dit ce qu'il mesure et quoi faire s'il change de couleur."}}) -%}
|
||||||
|
{%- set y.v = y.v + 3 -%}
|
||||||
|
|
||||||
|
{#- ---- RANGEE 1 : FAUT-IL AGIR ? ----------------------------------------------------- #}
|
||||||
|
{%- set y.id = y.id + 1 -%}
|
||||||
|
{%- set _ = panneaux.append({"type": "row", "id": y.id, "title": "Faut-il agir ?", "collapsed": false,
|
||||||
|
"gridPos": {"h": 1, "w": 24, "x": 0, "y": y.v}}) -%}
|
||||||
|
{%- set y.v = y.v + 1 -%}
|
||||||
|
|
||||||
|
{%- set tuiles = [] -%}
|
||||||
|
{%- if fabric -%}
|
||||||
|
{%- set crit = [] -%}
|
||||||
|
{%- set avert = [] -%}
|
||||||
|
{%- for c in cat.controles -%}
|
||||||
|
{%- set _ = (crit if c.niveau == "crit" else avert).append(c.expr) -%}
|
||||||
|
{%- endfor -%}
|
||||||
|
{%- for f in cat.temperatures -%}
|
||||||
|
{%- if f.crit is not none -%}{%- set _ = crit.append("(" ~ f.expr_juge ~ ") > " ~ f.crit) -%}{%- endif -%}
|
||||||
|
{%- if f.avert is not none -%}{%- set _ = avert.append("(" ~ f.expr_juge ~ ") > " ~ f.avert) -%}{%- endif -%}
|
||||||
|
{%- endfor -%}
|
||||||
|
{%- set _ = tuiles.append({
|
||||||
|
"titre": "Matériel des hyperviseurs",
|
||||||
|
"expr": "(count((" ~ (crit | join(") or (")) ~ ")) > 0) * 0 + 2 or (count((" ~ (avert | join(") or (")) ~ ")) > 0) * 0 + 1 or vector(0)",
|
||||||
|
"unite": "none",
|
||||||
|
"seuils": [{"color": "green", "value": none}, {"color": "orange", "value": 1}, {"color": "red", "value": 2}],
|
||||||
|
"mappings": [{"type": "value", "options": {"0": {"text": "Rien à faire"}, "1": {"text": "À planifier"}, "2": {"text": "À traiter"}}}],
|
||||||
|
"desc": "Le pire verdict du tableau Matériel : températures et contrôles des disques, ventilateurs et NVMe, avec les mêmes seuils que le service Icinga « materiel ».\n\n**Si c'est orange ou rouge** : ouvrir le tableau « Set-OPS — Matériel » ; la tuile colorée y dit quel équipement, et sa description dit quoi faire."}) -%}
|
||||||
|
{%- set _ = tuiles.append({
|
||||||
|
"titre": "Swap le plus plein (hyperviseurs)",
|
||||||
|
"expr": "max(100 * (1 - node_memory_SwapFree_bytes{job=\"fabric\"} / node_memory_SwapTotal_bytes{job=\"fabric\"}))",
|
||||||
|
"unite": "percent",
|
||||||
|
"seuils": [{"color": "green", "value": none}, {"color": "orange", "value": 50}, {"color": "red", "value": 90}],
|
||||||
|
"desc": "Le swap le plus rempli des trois hyperviseurs. Un hyperviseur qui swappe ralentit toutes les VM qu'il porte, sans aucune erreur visible.\n\n**Seuils** : orange à 50 %, rouge à 90 %.\n\n**Si c'est orange ou rouge** : la mémoire allouée aux VM dépasse ce que la machine peut tenir. Réduire la RAM des VM qui en ont trop (voir « Mémoire utilisée par machine »), arrêter les VM inutiles, ou déplacer une VM vers l'hyperviseur le moins chargé."}) -%}
|
||||||
|
{%- endif -%}
|
||||||
|
{%- set _ = tuiles.append({
|
||||||
|
"titre": "Disque le plus plein (machines)",
|
||||||
|
"expr": "max(100 * (1 - node_filesystem_avail_bytes{job=\"node\",mountpoint=\"/\"} / node_filesystem_size_bytes{job=\"node\",mountpoint=\"/\"}))",
|
||||||
|
"unite": "percent",
|
||||||
|
"seuils": [{"color": "green", "value": none}, {"color": "orange", "value": 80}, {"color": "red", "value": 90}],
|
||||||
|
"desc": "Le disque système le plus rempli parmi les machines de cet écosystème.\n\n**Seuils** : orange à 80 %, rouge à 90 % — les mêmes que la sonde Icinga « disque ».\n\n**Si c'est orange ou rouge** : trouver la machine dans « Disque utilisé par machine », plus bas ; chercher ce qui grossit (journaux, cache apt, sauvegardes locales) ou agrandir son disque."}) -%}
|
||||||
|
{%- set _ = tuiles.append({
|
||||||
|
"titre": "Correctifs de sécurité en attente",
|
||||||
|
"expr": "sum(apt_upgrades_pending{origin=~\"(?i).*securit.*\"}) or vector(0)",
|
||||||
|
"unite": "none",
|
||||||
|
"seuils": [{"color": "green", "value": none}, {"color": "orange", "value": 1}],
|
||||||
|
"desc": "Nombre de paquets de sécurité Debian prêts à être installés, toutes machines confondues" ~ (" (hyperviseurs compris)" if fabric else "") ~ ".\n\n**Seuil** : orange dès 1.\n\n**Si c'est orange** : les machines du socle les appliquent d'elles-mêmes dans la nuit ; si le compte ne redescend pas d'ici le lendemain, la sonde Icinga « correctifs » dit lesquelles bloquent." ~ (" Les hyperviseurs, eux, se mettent à jour à la main." if fabric else "")}) -%}
|
||||||
|
{%- set _ = tuiles.append({
|
||||||
|
"titre": "Redémarrages en attente",
|
||||||
|
"expr": "count(node_reboot_required == 1) or vector(0)",
|
||||||
|
"unite": "none",
|
||||||
|
"seuils": [{"color": "green", "value": none}, {"color": "orange", "value": 1}],
|
||||||
|
"desc": "Machines dont une mise à jour (noyau, bibliothèque système) ne prendra effet qu'au prochain redémarrage.\n\n**Seuil** : orange dès 1.\n\n**Si c'est orange** : planifier les redémarrages, une machine à la fois, en dehors des heures d'usage. Tant qu'elles ne redémarrent pas, le correctif est installé mais pas actif."}) -%}
|
||||||
|
{%- set _ = tuiles.append({
|
||||||
|
"titre": "Machines muettes",
|
||||||
|
"expr": "count(up == 0) or vector(0)",
|
||||||
|
"unite": "none",
|
||||||
|
"seuils": [{"color": "green", "value": none}, {"color": "red", "value": 1}],
|
||||||
|
"desc": "Cibles que Prometheus n'arrive plus à interroger : machine arrêtée, exportateur tombé, ou flux coupé.\n\n**Seuil** : rouge dès 1.\n\n**Si c'est rouge** : ce tableau ne voit plus cette machine, et tous ses chiffres sont figés. Vérifier qu'elle tourne, puis la sonde Icinga « collecte », qui la nomme."}) -%}
|
||||||
|
|
||||||
|
{%- set largeur = 24 // (tuiles | length) -%}
|
||||||
|
{%- for t in tuiles -%}
|
||||||
|
{%- set y.id = y.id + 1 -%}
|
||||||
|
{%- set _ = panneaux.append({
|
||||||
|
"type": "stat", "id": y.id, "title": t.titre, "description": t.desc, "datasource": ds,
|
||||||
|
"gridPos": {"h": 5, "w": largeur, "x": loop.index0 * largeur, "y": y.v},
|
||||||
|
"fieldConfig": {"defaults": {"unit": t.unite, "decimals": 0, "noValue": "—",
|
||||||
|
"mappings": t.mappings | default([]),
|
||||||
|
"thresholds": {"mode": "absolute", "steps": t.seuils}}, "overrides": []},
|
||||||
|
"options": {"colorMode": "background", "graphMode": "none", "justifyMode": "center",
|
||||||
|
"textMode": "value", "orientation": "auto",
|
||||||
|
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}},
|
||||||
|
"targets": [{"refId": "A", "datasource": ds, "expr": t.expr}]}) -%}
|
||||||
|
{%- endfor -%}
|
||||||
|
{%- set y.v = y.v + 5 -%}
|
||||||
|
|
||||||
|
{#- ---- RANGEE 2 : RESTE-T-IL DE LA MARGE ? ----------------------------------------- #}
|
||||||
|
{%- set y.id = y.id + 1 -%}
|
||||||
|
{%- set _ = panneaux.append({"type": "row", "id": y.id, "title": "Reste-t-il de la marge ?", "collapsed": false,
|
||||||
|
"gridPos": {"h": 1, "w": 24, "x": 0, "y": y.v}}) -%}
|
||||||
|
{%- set y.v = y.v + 1 -%}
|
||||||
|
{%- set w = 8 if fabric else 12 -%}
|
||||||
|
{%- set x = namespace(v=0) -%}
|
||||||
|
{%- if fabric -%}
|
||||||
|
{%- set y.id = y.id + 1 -%}
|
||||||
|
{%- set _ = panneaux.append({
|
||||||
|
"type": "timeseries", "id": y.id, "title": "Mémoire et swap des hyperviseurs", "datasource": ds,
|
||||||
|
"description": "Pour chaque hyperviseur : la part de sa RAM utilisée, et celle de son swap.\n\n**Comment le lire** : une RAM plate autour de 80 % avec un swap qui monte veut dire que les VM réclament plus que la machine n'a. Le swap n'est pas une marge, c'est un ralentissement.\n\n**Seuils** (pointillés) : 80 % et 90 %.\n\n**Si une courbe de swap reste haute** : réduire la RAM allouée aux VM de cet hyperviseur, ou en déplacer une.",
|
||||||
|
"gridPos": {"h": 9, "w": w, "x": x.v, "y": y.v},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "percent", "min": 0, "max": 100, "decimals": 0, "color": {"mode": "palette-classic"},
|
||||||
|
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": none}, {"color": "orange", "value": 80}, {"color": "red", "value": 90}]},
|
||||||
|
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 5, "showPoints": "never", "spanNulls": true, "thresholdsStyle": {"mode": "dashed"}}},
|
||||||
|
"overrides": []},
|
||||||
|
"options": {"legend": {"displayMode": "table", "placement": "bottom", "calcs": ["lastNotNull", "max"]}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||||
|
"targets": [
|
||||||
|
{"refId": "A", "datasource": ds, "legendFormat": "{{nodename}} · RAM",
|
||||||
|
"expr": "100 * (1 - node_memory_MemAvailable_bytes{job=\"fabric\"} / node_memory_MemTotal_bytes{job=\"fabric\"})" ~ nom},
|
||||||
|
{"refId": "B", "datasource": ds, "legendFormat": "{{nodename}} · swap",
|
||||||
|
"expr": "100 * (1 - node_memory_SwapFree_bytes{job=\"fabric\"} / node_memory_SwapTotal_bytes{job=\"fabric\"})" ~ nom}]}) -%}
|
||||||
|
{%- set x.v = x.v + w -%}
|
||||||
|
{%- endif -%}
|
||||||
|
{%- for p in [
|
||||||
|
{"titre": "Disque utilisé par machine", "seuils": [80, 90],
|
||||||
|
"expr": "100 * (1 - node_filesystem_avail_bytes{job=\"node\",mountpoint=\"/\"} / node_filesystem_size_bytes{job=\"node\",mountpoint=\"/\"})",
|
||||||
|
"desc": "Part utilisée du disque système de chaque machine, de la plus pleine à la moins pleine.\n\n**Seuils** : orange à 80 %, rouge à 90 %.\n\n**Si une barre passe à l'orange** : se connecter à la machine et chercher ce qui grossit (`du -xh / | sort -h | tail`) — le plus souvent des journaux ou un cache ; sinon, agrandir son disque."},
|
||||||
|
{"titre": "Mémoire utilisée par machine", "seuils": [85, 95],
|
||||||
|
"expr": "100 * (1 - node_memory_MemAvailable_bytes{job=\"node\"} / node_memory_MemTotal_bytes{job=\"node\"})",
|
||||||
|
"desc": "Part de la RAM que chaque machine utilise vraiment (cache disque exclu).\n\n**Comment le lire** : une barre très courte sur une machine qui a beaucoup de RAM, c'est de la mémoire qu'on pourrait rendre à l'hyperviseur" ~ (" — et c'est exactement ce qui manque quand le swap des hyperviseurs monte." if fabric else ".") ~ "\n\n**Seuils** : orange à 85 %, rouge à 95 %.\n\n**Si une barre passe au rouge** : la machine manque de mémoire ; lui en donner un peu plus, ou alléger ce qu'elle fait tourner."}
|
||||||
|
] -%}
|
||||||
|
{%- set y.id = y.id + 1 -%}
|
||||||
|
{%- set _ = panneaux.append({
|
||||||
|
"type": "bargauge", "id": y.id, "title": p.titre, "description": p.desc, "datasource": ds,
|
||||||
|
"gridPos": {"h": 9, "w": w, "x": x.v, "y": y.v},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "percent", "min": 0, "max": 100, "decimals": 0,
|
||||||
|
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": none}, {"color": "orange", "value": p.seuils[0]}, {"color": "red", "value": p.seuils[1]}]}},
|
||||||
|
"overrides": []},
|
||||||
|
"options": {"displayMode": "gradient", "orientation": "horizontal", "showUnfilled": true, "valueMode": "color",
|
||||||
|
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}},
|
||||||
|
"targets": [{"refId": "A", "datasource": ds, "legendFormat": "{{nodename}}",
|
||||||
|
"expr": "sort_desc(" ~ p.expr ~ nom ~ ")"}]}) -%}
|
||||||
|
{%- set x.v = x.v + w -%}
|
||||||
|
{%- endfor -%}
|
||||||
|
{%- set y.v = y.v + 9 -%}
|
||||||
|
|
||||||
|
{#- ---- RANGEE 3 : QUE SE PASSE-T-IL ? ---------------------------------------------- #}
|
||||||
|
{%- set y.id = y.id + 1 -%}
|
||||||
|
{%- set _ = panneaux.append({"type": "row", "id": y.id, "title": "Que se passe-t-il ?", "collapsed": false,
|
||||||
|
"gridPos": {"h": 1, "w": 24, "x": 0, "y": y.v}}) -%}
|
||||||
|
{%- set y.v = y.v + 1 -%}
|
||||||
|
{%- set job = 'job="fabric"' if fabric else 'job="node"' -%}
|
||||||
|
{%- set qui = "hyperviseur" if fabric else "machine" -%}
|
||||||
|
{%- set y.id = y.id + 1 -%}
|
||||||
|
{%- set _ = panneaux.append({
|
||||||
|
"type": "timeseries", "id": y.id, "title": "Processeur par " ~ qui, "datasource": ds,
|
||||||
|
"description": "Part du processeur occupée, par " ~ qui ~ ".\n\n**Comment le lire** : une courbe basse et plate est normale" ~ (" — 72 cœurs pour la flotte, occupés à quelques pour cent" if fabric else "") ~ ". Les pics racontent ce qui se passe : une reconstruction, une sauvegarde, un déploiement. Une courbe qui reste haute sans raison mérite qu'on cherche quelle machine consomme.\n\n**Si une courbe reste au-dessus de 80 %** : ouvrir le tableau « client metrique » pour voir quelle machine en est la cause.",
|
||||||
|
"gridPos": {"h": 8, "w": 12, "x": 0, "y": y.v},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "percent", "min": 0, "decimals": 0, "color": {"mode": "palette-classic"},
|
||||||
|
"custom": {"drawStyle": "line", "lineWidth": 1, "fillOpacity": 10, "showPoints": "never", "spanNulls": true}}, "overrides": []},
|
||||||
|
"options": {"legend": {"displayMode": "table", "placement": "right", "calcs": ["mean", "max"]}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||||
|
"targets": [{"refId": "A", "datasource": ds, "legendFormat": "{{nodename}}",
|
||||||
|
"expr": "100 * (1 - avg by (instance) (rate(node_cpu_seconds_total{mode=\"idle\"," ~ job ~ "}[5m])))" ~ nom}]}) -%}
|
||||||
|
{%- set y.id = y.id + 1 -%}
|
||||||
|
{%- set _ = panneaux.append({
|
||||||
|
"type": "timeseries", "id": y.id, "title": "Trafic réseau par " ~ qui, "datasource": ds,
|
||||||
|
"description": "Débit entrant et sortant additionnés, par " ~ qui ~ " (interfaces physiques seulement).\n\n**Comment le lire** : " ~ ("le trafic courant est surtout celui de Ceph et des sauvegardes ; les grands pics marquent une reconstruction ou un déplacement de VM." if fabric else "les pics marquent les sauvegardes de la nuit, les mises à jour et les échanges entre services.") ~ "\n\n**Si un pic dure sans explication** : croiser avec le tableau « Journaux de la flotte » à la même heure.",
|
||||||
|
"gridPos": {"h": 8, "w": 12, "x": 12, "y": y.v},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "Bps", "min": 0, "decimals": 1, "color": {"mode": "palette-classic"},
|
||||||
|
"custom": {"drawStyle": "line", "lineWidth": 1, "fillOpacity": 10, "showPoints": "never", "spanNulls": true}}, "overrides": []},
|
||||||
|
"options": {"legend": {"displayMode": "table", "placement": "right", "calcs": ["mean", "max"]}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||||
|
"targets": [{"refId": "A", "datasource": ds, "legendFormat": "{{nodename}}",
|
||||||
|
"expr": "sum by (instance) (rate(node_network_receive_bytes_total{" ~ job ~ ",device=~\"(en|eth).*\"}[5m]) + rate(node_network_transmit_bytes_total{" ~ job ~ ",device=~\"(en|eth).*\"}[5m]))" ~ nom}]}) -%}
|
||||||
|
{%- set y.v = y.v + 8 -%}
|
||||||
|
|
||||||
|
{#- ---- RANGEE 4 : OU REGARDER ENSUITE ? -------------------------------------------- #}
|
||||||
|
{%- set y.id = y.id + 1 -%}
|
||||||
|
{%- set _ = panneaux.append({"type": "row", "id": y.id, "title": "Où regarder ensuite ?", "collapsed": false,
|
||||||
|
"gridPos": {"h": 1, "w": 24, "x": 0, "y": y.v}}) -%}
|
||||||
|
{%- set y.v = y.v + 1 -%}
|
||||||
|
{%- set liens = [] -%}
|
||||||
|
{%- if fabric -%}{%- set _ = liens.append("- [**Matériel**](/d/setops-materiel) — températures, ventilateurs, disques des hyperviseurs et de la frontière.") -%}{%- endif -%}
|
||||||
|
{%- set _ = liens.append("- [**Machines**](/d/setops-client-metrique) — processeur, mémoire et disque de chaque machine, en détail.") -%}
|
||||||
|
{%- set _ = liens.append("- [**PostgreSQL**](/d/setops-postgresql) — bases de données : taille, connexions, requêtes.") -%}
|
||||||
|
{%- set _ = liens.append("- [**Journaux de la flotte**](/d/setops-journaux) — ce que les machines ont écrit, et quand.") -%}
|
||||||
|
{%- set _ = liens.append("- [**Vigie**](https://vigie." ~ domaine_interne ~ ") — les verdicts d'Icinga : ce qui est cassé, service par service.") -%}
|
||||||
|
{%- set y.id = y.id + 1 -%}
|
||||||
|
{%- set _ = panneaux.append({
|
||||||
|
"type": "text", "id": y.id, "title": "",
|
||||||
|
"gridPos": {"h": 5, "w": 24, "x": 0, "y": y.v},
|
||||||
|
"options": {"mode": "markdown", "content": liens | join("\n")}}) -%}
|
||||||
|
|
||||||
|
{{ {
|
||||||
|
"uid": "setops-synthese",
|
||||||
|
"title": "Set-OPS — Synthèse",
|
||||||
|
"description": "La porte d'entrée de l'observatoire : faut-il agir, reste-t-il de la marge, que se passe-t-il, où regarder ensuite.",
|
||||||
|
"tags": ["set-ops", "derive", "synthese"],
|
||||||
|
"timezone": "browser",
|
||||||
|
"refresh": "1m",
|
||||||
|
"time": {"from": "now-7d", "to": "now"},
|
||||||
|
"schemaVersion": 39,
|
||||||
|
"version": 1,
|
||||||
|
"graphTooltip": 1,
|
||||||
|
"templating": {"list": [
|
||||||
|
{"name": "ds_prom", "label": "Source Prometheus", "type": "datasource", "query": "prometheus", "hide": 2, "current": {}}
|
||||||
|
]},
|
||||||
|
"panels": panneaux
|
||||||
|
} | tojson }}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
# Supervision derivee du role. Voir docs/supervision-conception.md.
|
# Supervision derivee du role. Voir docs/supervision-conception.md.
|
||||||
sondes:
|
sondes:
|
||||||
- nom: moteur
|
- nom: moteur
|
||||||
|
metier: supervision
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
raison: 'L''etat des controles arrive-t-il encore jusqu''a la base ? On ne mesure ni le service ni
|
raison: 'L''etat des controles arrive-t-il encore jusqu''a la base ? On ne mesure ni le service ni
|
||||||
le port : on mesure la FRAICHEUR de ce que la base contient. Un moteur vert dont la synchronisation
|
le port : on mesure la FRAICHEUR de ce que la base contient. Un moteur vert dont la synchronisation
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,41 @@
|
||||||
# (2026-08-13), `hostname -f` rend le nom complet et Icinga s'emet spontanement un
|
# (2026-08-13), `hostname -f` rend le nom complet et Icinga s'emet spontanement un
|
||||||
# certificat CN et SAN = FQDN. Rien a forcer : il suffisait que la machine sache
|
# certificat CN et SAN = FQDN. Rien a forcer : il suffisait que la machine sache
|
||||||
# comment elle s'appelle.
|
# comment elle s'appelle.
|
||||||
|
# L'AC D'UNE INCARNATION PRECEDENTE REVIENT AVANT `api setup` (2026-10-07). Icinga 2 en
|
||||||
|
# derive l'environnement d'Icinga DB, donc l'identifiant de chaque hote, service et ligne
|
||||||
|
# d'historique. `api setup` la reutilise si elle est la (« Found CA, skipping and using the
|
||||||
|
# existing one », mesure sur mon-01) ; sinon il en fabrique une, et l'historique restaure
|
||||||
|
# par serveur_postgresql deviendrait orphelin. Aucun paquet n'en cree : c'est donc ICI.
|
||||||
|
- name: L'AC d'Icinga existe-t-elle deja ?
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: "{{ serveur_icinga_ca }}"
|
||||||
|
register: serveur_icinga_ca_presente
|
||||||
|
|
||||||
|
- name: Restauration — l'AC d'une incarnation precedente ?
|
||||||
|
ansible.builtin.include_role:
|
||||||
|
name: client_backup
|
||||||
|
tasks_from: restaurer.yml
|
||||||
|
vars:
|
||||||
|
client_backup_restaurer_jeu: serveur_icinga
|
||||||
|
client_backup_restaurer_vierge: "{{ not serveur_icinga_ca_presente.stat.exists }}"
|
||||||
|
|
||||||
|
- name: Restauration — remettre l'AC et l'environnement d'Icinga DB
|
||||||
|
changed_when: true
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv: >-
|
||||||
|
{{ ['/usr/local/sbin/setops-restaurer', 'fichiers',
|
||||||
|
'--instantane', client_backup_restauration.instantane,
|
||||||
|
'--proprietaire', 'nagios:nagios'] + client_backup_restauration.chemins }}
|
||||||
|
when: client_backup_restauration.etat == 'a_restaurer'
|
||||||
|
|
||||||
|
- name: Restauration — acter l'AC remise
|
||||||
|
ansible.builtin.include_role:
|
||||||
|
name: client_backup
|
||||||
|
tasks_from: acter.yml
|
||||||
|
vars:
|
||||||
|
client_backup_acter_etat: restaure
|
||||||
|
when: client_backup_restauration.etat == 'a_restaurer'
|
||||||
|
|
||||||
- name: Configurer l'API Icinga 2
|
- name: Configurer l'API Icinga 2
|
||||||
ansible.builtin.command:
|
ansible.builtin.command:
|
||||||
cmd: icinga2 api setup
|
cmd: icinga2 api setup
|
||||||
|
|
@ -174,12 +209,20 @@
|
||||||
enabled: true
|
enabled: true
|
||||||
state: started
|
state: started
|
||||||
|
|
||||||
- name: Importer le schema Icinga DB dans PostgreSQL (une fois)
|
# SUR UNE BASE VIERGE SEULEMENT (2026-10-07). Le marqueur vit sur mon-01 : une machine
|
||||||
|
# reconstruite ne l'a plus, alors que la base, elle, revient restauree par serveur_postgresql
|
||||||
|
# (historique compris). Rejouer le schema par-dessus y ajouterait une version et des erreurs
|
||||||
|
# « already exists » que psql taisait. La base dit elle-meme si elle a deja son schema.
|
||||||
|
- name: Importer le schema Icinga DB dans PostgreSQL (une fois, base vierge)
|
||||||
ansible.builtin.shell:
|
ansible.builtin.shell:
|
||||||
cmd: >-
|
cmd: >-
|
||||||
PGPASSWORD='{{ serveur_icinga_db_password }}'
|
export PGPASSWORD='{{ serveur_icinga_db_password }}';
|
||||||
|
deja=$(psql -h {{ serveur_icinga_db_host }} -U {{ serveur_icinga_entree.proprietaire }}
|
||||||
|
-d {{ serveur_icinga_entree.base }} -tAX
|
||||||
|
-c "select to_regclass('public.icingadb_schema') is not null");
|
||||||
|
if [ "$deja" != t ]; then
|
||||||
psql -h {{ serveur_icinga_db_host }} -U {{ serveur_icinga_entree.proprietaire }}
|
psql -h {{ serveur_icinga_db_host }} -U {{ serveur_icinga_entree.proprietaire }}
|
||||||
-d {{ serveur_icinga_entree.base }} -f {{ serveur_icinga_schema }}
|
-d {{ serveur_icinga_entree.base }} -f {{ serveur_icinga_schema }}; fi
|
||||||
&& touch /etc/icingadb/.schema-imported
|
&& touch /etc/icingadb/.schema-imported
|
||||||
creates: /etc/icingadb/.schema-imported
|
creates: /etc/icingadb/.schema-imported
|
||||||
no_log: true
|
no_log: true
|
||||||
|
|
@ -451,7 +494,7 @@
|
||||||
state: directory
|
state: directory
|
||||||
owner: root
|
owner: root
|
||||||
group: root
|
group: root
|
||||||
mode: "0755"
|
mode: "0700" # comme client_sante et client_backup (test_repertoires_partages.py)
|
||||||
delegate_to: "{{ groups['serveur_backup'] | first }}"
|
delegate_to: "{{ groups['serveur_backup'] | first }}"
|
||||||
|
|
||||||
- name: Deposer l'AC sur le depot de sauvegarde
|
- name: Deposer l'AC sur le depot de sauvegarde
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,11 @@
|
||||||
(`defini_par`, par defaut le role declarant) — la valeur meme que ce role voit.
|
(`defini_par`, par defaut le role declarant) — la valeur meme que ce role voit.
|
||||||
`test_sondes_conditionnelles.py` garde l'accord entre le `when:` et la declaration.
|
`test_sondes_conditionnelles.py` garde l'accord entre le `when:` et la declaration.
|
||||||
#}
|
#}
|
||||||
|
{#
|
||||||
|
UNE FRAICHEUR PAR SONDE, SI ELLE LA DECLARE (2026-09-30). `connectivite` bat a la minute :
|
||||||
|
attendue au quart d'heure et demi comme les autres, son silence ne se verrait qu'au bout
|
||||||
|
de 90 min. `fraicheur:` dans `meta/supervision.yml`, sinon la valeur commune.
|
||||||
|
#}
|
||||||
{% for role, sondes in (serveur_icinga_sondes | default({})) | dictsort %}
|
{% for role, sondes in (serveur_icinga_sondes | default({})) | dictsort %}
|
||||||
{% for hote in ((groups[role] | default([])) | intersect(groups['client_sante'] | default([]))) | sort %}
|
{% for hote in ((groups[role] | default([])) | intersect(groups['client_sante'] | default([]))) | sort %}
|
||||||
{% for sonde in sondes %}
|
{% for sonde in sondes %}
|
||||||
|
|
@ -50,12 +55,13 @@
|
||||||
{% set valeur = hostvars[hote][condition] if hostvars[hote][condition] is defined else defauts.get(condition) %}
|
{% set valeur = hostvars[hote][condition] if hostvars[hote][condition] is defined else defauts.get(condition) %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if not condition or valeur %}
|
{% if not condition or valeur %}
|
||||||
|
{% set fraicheur = sonde.fraicheur | default(serveur_icinga_fraicheur_sondes) %}
|
||||||
object Service "{{ sonde.nom }}" {
|
object Service "{{ sonde.nom }}" {
|
||||||
import "setops-rapport-attendu"
|
import "setops-rapport-attendu"
|
||||||
host_name = "{{ hote }}"
|
host_name = "{{ hote }}"
|
||||||
check_interval = {{ serveur_icinga_fraicheur_sondes }}s
|
check_interval = {{ fraicheur }}s
|
||||||
retry_interval = {{ serveur_icinga_fraicheur_sondes }}s
|
retry_interval = {{ fraicheur }}s
|
||||||
vars.dummy_text = "AUCUN RAPPORT depuis {{ (serveur_icinga_fraicheur_sondes / 60) | int }} min : cette sonde ne rapporte plus, ou n a jamais rapporte."
|
vars.dummy_text = "AUCUN RAPPORT depuis {{ (fraicheur / 60) | int }} min : cette sonde ne rapporte plus, ou n a jamais rapporte."
|
||||||
vars.setops_source = "{{ hote }}"
|
vars.setops_source = "{{ hote }}"
|
||||||
vars.setops_role = "{{ role }}"
|
vars.setops_role = "{{ role }}"
|
||||||
vars.setops_raison = "{{ (sonde.raison | default('')) | replace('"', "'") | replace('\n', ' ') | trim }}"
|
vars.setops_raison = "{{ (sonde.raison | default('')) | replace('"', "'") | replace('\n', ' ') | trim }}"
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,19 @@ serveur_icingaweb2_paquets:
|
||||||
- php8.4-mbstring
|
- php8.4-mbstring
|
||||||
- nginx
|
- nginx
|
||||||
|
|
||||||
|
# INSTALLES AVANT LES PAQUETS TIERS, pour qu'apt ne choisisse pas apache2 (voir tasks).
|
||||||
|
serveur_icingaweb2_web_prealables:
|
||||||
|
- php8.4-fpm
|
||||||
|
- nginx
|
||||||
|
|
||||||
|
# Ce qu'un ordre d'installation fautif a laisse, et que le role retire s'il part seul.
|
||||||
|
serveur_icingaweb2_apache_restes:
|
||||||
|
- apache2
|
||||||
|
- apache2-bin
|
||||||
|
- apache2-data
|
||||||
|
- apache2-utils
|
||||||
|
- libapache2-mod-php8.4
|
||||||
|
|
||||||
serveur_icingaweb2_php_fpm_service: "php8.4-fpm"
|
serveur_icingaweb2_php_fpm_service: "php8.4-fpm"
|
||||||
serveur_icingaweb2_php_fpm_socket: "/run/php/php8.4-fpm.sock"
|
serveur_icingaweb2_php_fpm_socket: "/run/php/php8.4-fpm.sock"
|
||||||
serveur_icingaweb2_nginx_service: "nginx"
|
serveur_icingaweb2_nginx_service: "nginx"
|
||||||
|
|
@ -105,6 +118,69 @@ serveur_icingaweb2_modules:
|
||||||
# les processus sont normalement édités via l'UI. Un contenu ici est géré par Ansible.
|
# les processus sont normalement édités via l'UI. Un contenu ici est géré par Ansible.
|
||||||
serveur_icingaweb2_bpm_processes: {}
|
serveur_icingaweb2_bpm_processes: {}
|
||||||
|
|
||||||
|
# --- LES VUES METIER, DERIVEES (2026-10-04) --------------------------------------------
|
||||||
|
#
|
||||||
|
# Une vue d'ensemble par CLIENTELE INTERNE, construite seule : chaque sonde declare dans le
|
||||||
|
# `meta/supervision.yml` de son role le service metier auquel elle contribue (`metier:`) ;
|
||||||
|
# ce catalogue dit comment chaque service s'appelle et QUI le voit. Les vues ne retiennent
|
||||||
|
# que les services presents dans l'ecosysteme : un site sans courriel n'a pas de case
|
||||||
|
# « courriel », un locataire sans forge pas de case « forge ».
|
||||||
|
#
|
||||||
|
# Fichiers poses : `setops-<vue>.conf` dans le repertoire des processus. Le prefixe les
|
||||||
|
# distingue des processus ecrits a la main ou dans l'interface, que le role ne touche pas.
|
||||||
|
serveur_icingaweb2_bpm_vues:
|
||||||
|
personnel:
|
||||||
|
titre: "Mes outils"
|
||||||
|
# PAS DE PERSONNEL SANS COMPTES : la vue n'existe que la ou l'on se connecte. Au site,
|
||||||
|
# le seul « outil » trouve etait son relais de courriel — celui des alertes.
|
||||||
|
requiert: connexion
|
||||||
|
description: >-
|
||||||
|
Les outils du quotidien fonctionnent-ils ? Vert : tout marche. Rouge : un outil est en
|
||||||
|
panne, et la supervision le voit aussi.
|
||||||
|
direction:
|
||||||
|
titre: "Services rendus"
|
||||||
|
description: >-
|
||||||
|
Les services que l'organisation rend sont-ils rendus, et ses données sont-elles en
|
||||||
|
sûreté ? Un voyant par service, et un pour les fondations techniques.
|
||||||
|
exploitation:
|
||||||
|
titre: "Exploitation"
|
||||||
|
description: >-
|
||||||
|
Qu'est-ce qui casse, et où ? Chaque service se déplie jusqu'au contrôle Icinga et à la
|
||||||
|
machine en cause ; le socle se lit machine par machine.
|
||||||
|
|
||||||
|
# L'ORDRE EST CELUI DE L'AFFICHAGE. `fondation: true` : regroupe sous « Fondations
|
||||||
|
# techniques » dans la vue de la direction. `par_machine: true` : un sous-noeud par machine.
|
||||||
|
serveur_icingaweb2_bpm_services:
|
||||||
|
- {cle: connexion, titre: "Se connecter (compte unique)", vues: [personnel, direction, exploitation]}
|
||||||
|
- {cle: courriel, titre: "Courriel", vues: [personnel, direction, exploitation]}
|
||||||
|
- {cle: fichiers, titre: "Fichiers partagés", vues: [personnel, direction, exploitation]}
|
||||||
|
- {cle: edition, titre: "Édition en ligne", vues: [personnel, direction, exploitation]}
|
||||||
|
- {cle: web, titre: "Sites web publics", vues: [personnel, direction, exploitation]}
|
||||||
|
- {cle: noms_publics, titre: "Noms publics des locataires", vues: [direction, exploitation]}
|
||||||
|
- {cle: sauvegardes_locataires, titre: "Sauvegardes des locataires", vues: [direction, exploitation]}
|
||||||
|
- {cle: forge, titre: "Forge et génome", vues: [direction, exploitation]}
|
||||||
|
- {cle: paquets, titre: "Dépôt de paquets", vues: [direction, exploitation]}
|
||||||
|
- {cle: machines, titre: "Création des machines", vues: [direction, exploitation]}
|
||||||
|
- {cle: donnees, titre: "Sûreté des données", vues: [direction, exploitation]}
|
||||||
|
# L'EDGE N'EST PAS LE WEB PUBLIC : c'est la passerelle par laquelle on joint les services
|
||||||
|
# internes. Classe « sites web publics », il faisait apparaitre une vue « Mes outils » au
|
||||||
|
# site, qui n'a pas d'utilisateurs.
|
||||||
|
- {cle: acces, titre: "Passerelle d'accès (edge)", vues: [direction, exploitation], fondation: true}
|
||||||
|
- {cle: frontiere, titre: "Frontière (une patte par zone)", vues: [direction, exploitation], fondation: true}
|
||||||
|
- {cle: noms, titre: "Résolution des noms", vues: [direction, exploitation], fondation: true}
|
||||||
|
- {cle: confiance, titre: "Autorité de certification", vues: [direction, exploitation], fondation: true}
|
||||||
|
- {cle: base, titre: "Bases de données", vues: [direction, exploitation], fondation: true}
|
||||||
|
- {cle: supervision, titre: "Supervision", vues: [direction, exploitation], fondation: true}
|
||||||
|
- {cle: pilotage, titre: "Pilotage (console et runner)", vues: [direction, exploitation], fondation: true}
|
||||||
|
- {cle: socle, titre: "Socle des machines", vues: [direction, exploitation], fondation: true, par_machine: true}
|
||||||
|
|
||||||
|
# Les processus que le role a poses autrefois et qu'il retire (sans extension).
|
||||||
|
# `supervision` : l'exemple ecrit a la main dans les group_vars des locataires — il visait
|
||||||
|
# un hote `icinga` et des controles (`load`, `procs`, `swap`, `ssh`) qui n'existent plus.
|
||||||
|
serveur_icingaweb2_bpm_retires: [supervision]
|
||||||
|
# Le nom de l'ecosysteme en tete de chaque vue.
|
||||||
|
serveur_icingaweb2_bpm_nom: "{{ organisation | default(domaine_interne) }}"
|
||||||
|
|
||||||
# LES CODES QU'UNE CONSOLE SAINE PEUT RENDRE A UNE REQUETE NON AUTHENTIFIEE.
|
# LES CODES QU'UNE CONSOLE SAINE PEUT RENDRE A UNE REQUETE NON AUTHENTIFIEE.
|
||||||
#
|
#
|
||||||
# 200 quand la racine se sert, 302 quand elle renvoie vers le formulaire de connexion,
|
# 200 quand la racine se sert, 302 quand elle renvoie vers le formulaire de connexion,
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,15 @@ flux:
|
||||||
# Les deux consoles de l'observabilite avaient la meme contrainte et une seule des deux
|
# Les deux consoles de l'observabilite avaient la meme contrainte et une seule des deux
|
||||||
# l'avait ecrite. En mode `locale`, nginx authentifie lui-meme : ce qui entre par le
|
# l'avait ecrite. En mode `locale`, nginx authentifie lui-meme : ce qui entre par le
|
||||||
# plan d'administration frappe un vestibule, pas l'application.
|
# plan d'administration frappe un vestibule, pas l'application.
|
||||||
|
#
|
||||||
|
# SEULEMENT SI NGINX ECOUTE SUR LE RESEAU (2026-09-30). En SSO, il est lie a 127.0.0.1
|
||||||
|
# derriere la passerelle (`serveur_icingaweb2_nginx_bind: "127.0.0.1:"`) : ouvrir ce port
|
||||||
|
# depuis l'edge ne menait a rien — la sonde `connectivite` le disait chaque minute.
|
||||||
- sens: ingress
|
- sens: ingress
|
||||||
port: 8080
|
port: 8080
|
||||||
protocole: tcp
|
protocole: tcp
|
||||||
pair: [edge, admin]
|
pair: [edge, admin]
|
||||||
|
seulement_si: {variable: serveur_icingaweb2_nginx_bind, egal: ""}
|
||||||
chiffrement: clair
|
chiffrement: clair
|
||||||
raison: >-
|
raison: >-
|
||||||
Interface web servie via l'edge (TLS terminé à l'edge ; SSO possible via
|
Interface web servie via l'edge (TLS terminé à l'edge ; SSO possible via
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
# dans les plans (`vigie.<domaine>`).
|
# dans les plans (`vigie.<domaine>`).
|
||||||
sondes:
|
sondes:
|
||||||
- nom: vigie
|
- nom: vigie
|
||||||
|
metier: supervision
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
raison: 'La vigie (Icinga Web 2) repond-elle, et lit-elle ce qu''elle doit montrer
|
raison: 'La vigie (Icinga Web 2) repond-elle, et lit-elle ce qu''elle doit montrer
|
||||||
(base du moteur, annuaire ou base des comptes, Redis) ? Si elle meurt, ou si elle
|
(base du moteur, annuaire ou base des comptes, Redis) ? Si elle meurt, ou si elle
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,13 @@
|
||||||
name: resoudre_base
|
name: resoudre_base
|
||||||
vars:
|
vars:
|
||||||
resoudre_base_groupe: "icingaweb2"
|
resoudre_base_groupe: "icingaweb2"
|
||||||
when: serveur_icingaweb2_auth == 'db'
|
# DANS TOUS LES MODES (2026-10-03), plus seulement `db`. Le cadre de migration d'Icinga
|
||||||
|
# Web 2 veut une base quoi qu'il arrive : sans elle, chaque page qui affiche le menu de
|
||||||
|
# configuration journalise « Failed to load pending migrations : Please check if a db
|
||||||
|
# instance exists at all » — 145 fois par heure chez Chezlepro, vigie ouverte, depuis
|
||||||
|
# la reconstruction. Le site avait recu sa base le 2026-09-15 ; les locataires, en SSO,
|
||||||
|
# etaient restes sans. Le badge est calcule sans condition de permission
|
||||||
|
# (`ConfigMenu::createMigrationBadge`) : aucun reglage de role ne le fait taire.
|
||||||
|
|
||||||
- name: Adopter la base des comptes pour Icinga Web 2
|
- name: Adopter la base des comptes pour Icinga Web 2
|
||||||
ansible.builtin.set_fact:
|
ansible.builtin.set_fact:
|
||||||
|
|
@ -62,7 +68,6 @@
|
||||||
serveur_icingaweb2_db_utilisateur: "{{ resoudre_base_entree.proprietaire }}"
|
serveur_icingaweb2_db_utilisateur: "{{ resoudre_base_entree.proprietaire }}"
|
||||||
serveur_icingaweb2_db_motdepasse: "{{ resoudre_base_db_password }}"
|
serveur_icingaweb2_db_motdepasse: "{{ resoudre_base_db_password }}"
|
||||||
no_log: true
|
no_log: true
|
||||||
when: serveur_icingaweb2_auth == 'db'
|
|
||||||
|
|
||||||
- name: Exiger un mot de passe pour le compte d'amorçage
|
- name: Exiger un mot de passe pour le compte d'amorçage
|
||||||
ansible.builtin.assert:
|
ansible.builtin.assert:
|
||||||
|
|
@ -74,9 +79,21 @@
|
||||||
passe deviné : renseigner la clé dans la voûte de cet écosystème.
|
passe deviné : renseigner la clé dans la voûte de cet écosystème.
|
||||||
when: serveur_icingaweb2_auth == 'db'
|
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
|
# 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.
|
# les objets existants — on lit donc d'abord si la table des comptes est la.
|
||||||
- name: La base des comptes est-elle déjà en place ?
|
- name: La base de la console est-elle déjà en place ?
|
||||||
community.postgresql.postgresql_query:
|
community.postgresql.postgresql_query:
|
||||||
login_host: "{{ serveur_icingaweb2_db_host }}"
|
login_host: "{{ serveur_icingaweb2_db_host }}"
|
||||||
login_user: "{{ serveur_icingaweb2_db_utilisateur }}"
|
login_user: "{{ serveur_icingaweb2_db_utilisateur }}"
|
||||||
|
|
@ -89,10 +106,9 @@
|
||||||
changed_when: false
|
changed_when: false
|
||||||
no_log: true
|
no_log: true
|
||||||
when:
|
when:
|
||||||
- serveur_icingaweb2_auth == 'db'
|
|
||||||
- not ansible_check_mode
|
- not ansible_check_mode
|
||||||
|
|
||||||
- name: Charger le schéma des comptes
|
- name: Charger le schéma de la console (comptes, préférences, migrations)
|
||||||
community.postgresql.postgresql_script:
|
community.postgresql.postgresql_script:
|
||||||
login_host: "{{ serveur_icingaweb2_db_host }}"
|
login_host: "{{ serveur_icingaweb2_db_host }}"
|
||||||
login_user: "{{ serveur_icingaweb2_db_utilisateur }}"
|
login_user: "{{ serveur_icingaweb2_db_utilisateur }}"
|
||||||
|
|
@ -103,7 +119,6 @@
|
||||||
path: "{{ serveur_icingaweb2_schema }}"
|
path: "{{ serveur_icingaweb2_schema }}"
|
||||||
no_log: true
|
no_log: true
|
||||||
when:
|
when:
|
||||||
- serveur_icingaweb2_auth == 'db'
|
|
||||||
- not ansible_check_mode
|
- not ansible_check_mode
|
||||||
- not (serveur_icingaweb2_schema_etat.query_result[0].presente | default(false))
|
- not (serveur_icingaweb2_schema_etat.query_result[0].presente | default(false))
|
||||||
|
|
||||||
|
|
@ -192,6 +207,18 @@
|
||||||
# `Acquire::https::Proxy "DIRECT"` — ils CONTOURNENT donc le cache du site et sortent sur
|
# `Acquire::https::Proxy "DIRECT"` — ils CONTOURNENT donc le cache du site et sortent sur
|
||||||
# Internet a chaque construction de VM. `make cacher-paquets` les tire une fois, versions
|
# Internet a chaque construction de VM. `make cacher-paquets` les tire une fois, versions
|
||||||
# epinglees et empreintes verifiees ; ce role les depose depuis ce cache.
|
# epinglees et empreintes verifiees ; ce role les depose depuis ce cache.
|
||||||
|
# PHP-FPM ET NGINX AVANT LES PAQUETS TIERS (2026-10-03). Les paquets d'Icinga exigent
|
||||||
|
# « un PHP pour le web », et apt choisit la PREMIERE alternative qu'aucun paquet present ne
|
||||||
|
# satisfait : `libapache2-mod-php`, qui tire apache2. Chez les locataires, tout s'installe
|
||||||
|
# en une transaction avec `php8.4-fpm`, et apt n'a pas a choisir. Au site, les paquets
|
||||||
|
# tiers viennent du cache du controleur, SEULS, avant le reste : `site-mon-01` portait un
|
||||||
|
# apache2 ecoutant sur le port 80 depuis le 2026-09-14, que rien n'utilisait.
|
||||||
|
- name: Installer PHP-FPM et nginx avant les paquets tiers (sinon apt choisit apache2)
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: "{{ serveur_icingaweb2_web_prealables }}"
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
|
||||||
- name: Poser les paquets tiers depuis le cache du controleur
|
- name: Poser les paquets tiers depuis le cache du controleur
|
||||||
ansible.builtin.include_role:
|
ansible.builtin.include_role:
|
||||||
name: paquets_tiers
|
name: paquets_tiers
|
||||||
|
|
@ -211,6 +238,27 @@
|
||||||
when: (serveur_icingaweb2_paquets
|
when: (serveur_icingaweb2_paquets
|
||||||
| difference((paquets_tiers_disponibles | default({})).keys() | list)) | length > 0
|
| difference((paquets_tiers_disponibles | default({})).keys() | list)) | length > 0
|
||||||
|
|
||||||
|
# LE RESTE D'UNE INSTALLATION DANS LE MAUVAIS ORDRE (voir plus haut). On ne le retire que
|
||||||
|
# si apt confirme qu'il part SEUL : `state: absent` emporterait aussi tout paquet qui en
|
||||||
|
# dependrait, et ce role n'a pas a decider de leur sort.
|
||||||
|
- name: Simuler le retrait d'apache2 (rien d'autre ne doit partir)
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv: "{{ ['apt-get', '-s', 'purge'] + serveur_icingaweb2_apache_restes }}"
|
||||||
|
register: serveur_icingaweb2_apache_simulation
|
||||||
|
changed_when: false
|
||||||
|
check_mode: false
|
||||||
|
|
||||||
|
- name: Retirer apache2, que rien n'utilise
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: "{{ serveur_icingaweb2_apache_restes }}"
|
||||||
|
state: absent
|
||||||
|
purge: true
|
||||||
|
when:
|
||||||
|
- serveur_icingaweb2_apache_simulation.stdout_lines | select('match', '^(Purg|Remv) ') | list | length > 0
|
||||||
|
- (serveur_icingaweb2_apache_simulation.stdout_lines | select('match', '^(Purg|Remv) ')
|
||||||
|
| map('regex_replace', '^(?:Purg|Remv) (\\S+).*$', '\\1') | list)
|
||||||
|
is subset(serveur_icingaweb2_apache_restes)
|
||||||
|
|
||||||
- name: Ajouter www-data au groupe icingaweb2 (lecture de la config)
|
- name: Ajouter www-data au groupe icingaweb2 (lecture de la config)
|
||||||
ansible.builtin.user:
|
ansible.builtin.user:
|
||||||
name: www-data
|
name: www-data
|
||||||
|
|
@ -274,6 +322,101 @@
|
||||||
label: "{{ item.key }}"
|
label: "{{ item.key }}"
|
||||||
when: serveur_icingaweb2_bpm_processes | length > 0
|
when: serveur_icingaweb2_bpm_processes | length > 0
|
||||||
|
|
||||||
|
# --- LES VUES METIER, DERIVEES (2026-10-04) — voir defaults/main.yml -------------------
|
||||||
|
#
|
||||||
|
# Meme lecture que `serveur_icinga` : les `meta/supervision.yml` de tous les roles, sur le
|
||||||
|
# controleur. Les vues ne nomment ainsi que ce qu'Icinga surveille vraiment.
|
||||||
|
- name: Vues métier — relever les sondes que les rôles déclarent
|
||||||
|
ansible.builtin.find:
|
||||||
|
paths: "{{ role_path }}/.."
|
||||||
|
patterns: supervision.yml
|
||||||
|
recurse: true
|
||||||
|
depth: 3
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
register: serveur_icingaweb2_metas_supervision
|
||||||
|
when: "'businessprocess' in serveur_icingaweb2_modules"
|
||||||
|
|
||||||
|
- name: Vues métier — lire chaque déclaration
|
||||||
|
ansible.builtin.slurp:
|
||||||
|
src: "{{ item.path }}"
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
loop: "{{ serveur_icingaweb2_metas_supervision.files | default([]) }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.path | dirname | dirname | basename }}"
|
||||||
|
register: serveur_icingaweb2_supervision_brute
|
||||||
|
when: "'businessprocess' in serveur_icingaweb2_modules"
|
||||||
|
|
||||||
|
# Les detenteurs d'etat appartiennent a `client_backup` : on les LIT chez lui, comme
|
||||||
|
# `serveur_icinga` — deux listes divergeraient, et la vue nommerait des sauvegardes
|
||||||
|
# qu'Icinga n'attend pas.
|
||||||
|
- name: Vues métier — lire la liste des détenteurs d'état chez client_backup
|
||||||
|
ansible.builtin.include_vars:
|
||||||
|
file: "{{ role_path }}/../client_backup/vars/main.yml"
|
||||||
|
name: serveur_icingaweb2_catalogue_sauvegarde
|
||||||
|
when: "'businessprocess' in serveur_icingaweb2_modules"
|
||||||
|
|
||||||
|
- name: Vues métier — assembler le registre des sondes et les sauvegardes attendues
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
serveur_icingaweb2_sondes: >-
|
||||||
|
{{ dict(serveur_icingaweb2_supervision_brute.results
|
||||||
|
| map(attribute='item.path') | map('dirname') | map('dirname') | map('basename')
|
||||||
|
| zip(serveur_icingaweb2_supervision_brute.results
|
||||||
|
| map(attribute='content') | map('b64decode') | map('from_yaml')
|
||||||
|
| map(attribute='sondes'))) }}
|
||||||
|
serveur_icingaweb2_bpm_hote_sauvegarde: "{{ (groups['serveur_backup'] | default([]) | first) | default('') }}"
|
||||||
|
serveur_icingaweb2_bpm_sauvegarde_attendue: >-
|
||||||
|
{{ (serveur_icingaweb2_catalogue_sauvegarde.client_backup_groupes_etat | default([])
|
||||||
|
| map('extract', groups) | select('defined') | flatten | unique | list)
|
||||||
|
| intersect(groups['client_backup'] | default([])) }}
|
||||||
|
when: "'businessprocess' in serveur_icingaweb2_modules"
|
||||||
|
|
||||||
|
- name: Vues métier — calculer les contrôles de chaque service
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
serveur_icingaweb2_bpm_membres: "{{ lookup('ansible.builtin.template', 'bpm-membres.json.j2') | from_json }}"
|
||||||
|
when: "'businessprocess' in serveur_icingaweb2_modules"
|
||||||
|
|
||||||
|
- name: Vues métier — retenir les vues qui ont au moins un service
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
serveur_icingaweb2_bpm_vues_posees: >-
|
||||||
|
{{ serveur_icingaweb2_bpm_vues | dict2items
|
||||||
|
| selectattr('key', 'in', serveur_icingaweb2_bpm_services
|
||||||
|
| selectattr('cle', 'in', serveur_icingaweb2_bpm_membres.keys() | list)
|
||||||
|
| map(attribute='vues') | flatten | unique | list)
|
||||||
|
| rejectattr('value.requiert', 'defined')
|
||||||
|
| map(attribute='key') | list
|
||||||
|
+ (serveur_icingaweb2_bpm_vues | dict2items
|
||||||
|
| selectattr('value.requiert', 'defined')
|
||||||
|
| selectattr('value.requiert', 'in', serveur_icingaweb2_bpm_membres.keys() | list)
|
||||||
|
| map(attribute='key') | list) }}
|
||||||
|
when: "'businessprocess' in serveur_icingaweb2_modules"
|
||||||
|
|
||||||
|
- name: Vues métier — poser chaque vue
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: bpm-vue.conf.j2
|
||||||
|
dest: "{{ serveur_icingaweb2_config_dir }}/modules/businessprocess/processes/setops-{{ bpm_vue }}.conf"
|
||||||
|
owner: root
|
||||||
|
group: icingaweb2
|
||||||
|
mode: "0660"
|
||||||
|
loop: "{{ serveur_icingaweb2_bpm_vues_posees }}"
|
||||||
|
loop_control:
|
||||||
|
loop_var: bpm_vue
|
||||||
|
when: "'businessprocess' in serveur_icingaweb2_modules"
|
||||||
|
|
||||||
|
# UNE VUE QUI N'A PLUS DE SERVICE SE RETIRE, et l'ancien exemple avec elle. Seuls les
|
||||||
|
# fichiers que ce role a poses sont concernes : un processus cree dans l'interface garde
|
||||||
|
# son nom, et le role n'y touche jamais.
|
||||||
|
- name: Vues métier — retirer les vues vides et les processus abandonnés
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ serveur_icingaweb2_config_dir }}/modules/businessprocess/processes/{{ item }}.conf"
|
||||||
|
state: absent
|
||||||
|
loop: >-
|
||||||
|
{{ (serveur_icingaweb2_bpm_vues.keys() | reject('in', serveur_icingaweb2_bpm_vues_posees | default([]))
|
||||||
|
| map('regex_replace', '^', 'setops-') | list)
|
||||||
|
+ serveur_icingaweb2_bpm_retires }}
|
||||||
|
when: "'businessprocess' in serveur_icingaweb2_modules"
|
||||||
|
|
||||||
- name: Déployer le vhost nginx local d'Icinga Web 2
|
- name: Déployer le vhost nginx local d'Icinga Web 2
|
||||||
ansible.builtin.template:
|
ansible.builtin.template:
|
||||||
src: nginx.conf.j2
|
src: nginx.conf.j2
|
||||||
|
|
|
||||||
53
roles/serveur_icingaweb2/templates/bpm-membres.json.j2
Normal file
53
roles/serveur_icingaweb2/templates/bpm-membres.json.j2
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
{#- LES CONTROLES DE CHAQUE SERVICE METIER (2026-10-04) — rendu en JSON, lu par les taches.
|
||||||
|
|
||||||
|
Meme derivation que `serveur_icinga/templates/setops-sondes.conf.j2`, a la ligne pres :
|
||||||
|
chaque role, ses hotes (`groups[role]` et `client_sante`), ses sondes, et la condition
|
||||||
|
`seulement_si` lue dans l'inventaire ou dans les defauts du role qui la definit. Une vue
|
||||||
|
ne doit nommer que des services qu'Icinga a REELLEMENT : un noeud absent s'afficherait
|
||||||
|
« manquant », et une vue qui ment est pire qu'une vue absente.
|
||||||
|
|
||||||
|
Les sauvegardes ne viennent pas des roles mais de `setops-sauvegardes.conf.j2`, et leur
|
||||||
|
nom change avec le modele : `sauvegarde: <noeud>` sur le depot local (site), `sauvegarde`
|
||||||
|
sur chaque noeud (locataire). On reprend ses deux variables, calculees dans les taches.
|
||||||
|
-#}
|
||||||
|
{%- set m = namespace(d={}) -%}
|
||||||
|
{%- for role, sondes in (serveur_icingaweb2_sondes | default({})) | dictsort -%}
|
||||||
|
{%- for hote in ((groups[role] | default([])) | intersect(groups['client_sante'] | default([]))) | sort -%}
|
||||||
|
{%- for sonde in sondes if sonde.metier is defined -%}
|
||||||
|
{%- set condition = sonde.seulement_si | default('') -%}
|
||||||
|
{%- set ns = namespace(ok=true) -%}
|
||||||
|
{%- if condition -%}
|
||||||
|
{%- set defauts = lookup('file', role_path ~ '/../' ~ (sonde.defini_par | default(role)) ~ '/defaults/main.yml') | from_yaml -%}
|
||||||
|
{#- LE MEME TEST QU'ICINGA, pas `| bool` : une valeur comme « frontiere » est vraie
|
||||||
|
pour lui (non vide) et fausse pour `bool` — la sonde existait, la vue l'omettait. -#}
|
||||||
|
{%- set ns.ok = (hostvars[hote][condition] if hostvars[hote][condition] is defined else defauts.get(condition)) and true -%}
|
||||||
|
{%- endif -%}
|
||||||
|
{%- if ns.ok -%}
|
||||||
|
{%- set _ = m.d.setdefault(sonde.metier, []).append([hote, sonde.nom]) -%}
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endfor -%}
|
||||||
|
{%- endfor -%}
|
||||||
|
{%- endfor -%}
|
||||||
|
{%- for noeud in serveur_icingaweb2_bpm_sauvegarde_attendue | sort -%}
|
||||||
|
{#- `sauvegarde` existe TOUJOURS sur le noeud ; `sauvegarde: <noeud>` S'Y AJOUTE sur le
|
||||||
|
depot quand l'ecosysteme en a un (le site) — les deux, pas l'un ou l'autre. -#}
|
||||||
|
{%- if serveur_icingaweb2_bpm_hote_sauvegarde -%}
|
||||||
|
{%- set _ = m.d.setdefault("donnees", []).append([serveur_icingaweb2_bpm_hote_sauvegarde, "sauvegarde: " ~ noeud]) -%}
|
||||||
|
{%- endif -%}
|
||||||
|
{%- set _ = m.d.setdefault("donnees", []).append([noeud, "sauvegarde"]) -%}
|
||||||
|
{%- set _ = m.d.setdefault("donnees", []).append([noeud, "restauration"]) -%}
|
||||||
|
{%- endfor -%}
|
||||||
|
{#- LE SOCLE COMPLET DE CHAQUE MACHINE : `sante` (unites en echec, `setops-sante.conf.j2`,
|
||||||
|
sur tout le socle `serveur_debian`) et `ping4` (chaque hote supervise) ne viennent pas des
|
||||||
|
roles ; au site, le `materiel` des hyperviseurs et de la frontiere (`setops_materiel_hotes`). -#}
|
||||||
|
{%- for noeud in groups['serveur_debian'] | default([]) | sort -%}
|
||||||
|
{%- set _ = m.d.setdefault("socle", []).extend([[noeud, "sante"], [noeud, "ping4"]]) -%}
|
||||||
|
{%- endfor -%}
|
||||||
|
{%- for h in setops_materiel_hotes | default([]) -%}
|
||||||
|
{%- set _ = m.d.setdefault("socle", []).extend([[h.nom, "materiel"], [h.nom, "ping4"]]) -%}
|
||||||
|
{%- endfor -%}
|
||||||
|
{#- LES PATTES DE LA FRONTIERE (site) : une par zone, que Icinga joint par `ping4`. -#}
|
||||||
|
{%- for f in serveur_icinga_fabric | default([]) -%}
|
||||||
|
{%- set _ = m.d.setdefault("frontiere", []).append([f.nom, "ping4"]) -%}
|
||||||
|
{%- endfor -%}
|
||||||
|
{{ m.d | to_json }}
|
||||||
44
roles/serveur_icingaweb2/templates/bpm-vue.conf.j2
Normal file
44
roles/serveur_icingaweb2/templates/bpm-vue.conf.j2
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
{#- UNE VUE METIER (2026-10-04) — `bpm_vue` (cle), sur les controles calcules par
|
||||||
|
`bpm-membres.json.j2`. Syntaxe : module businessprocess 2.5 (parseur « legacy ») —
|
||||||
|
`noeud = a & b`, `display <priorite>;<noeud>;<libelle>` (priorite 0 : libelle seul, pas
|
||||||
|
a la racine). -#}
|
||||||
|
{%- set vue = serveur_icingaweb2_bpm_vues[bpm_vue] -%}
|
||||||
|
{%- set membres = serveur_icingaweb2_bpm_membres -%}
|
||||||
|
{%- set services = serveur_icingaweb2_bpm_services | selectattr("vues", "contains", bpm_vue)
|
||||||
|
| selectattr("cle", "in", membres.keys() | list) | list -%}
|
||||||
|
{%- set regrouper = bpm_vue == "direction" -%}
|
||||||
|
### Business Process Config File ###
|
||||||
|
#
|
||||||
|
# Title : {{ serveur_icingaweb2_bpm_nom }} — {{ vue.titre }}
|
||||||
|
# Description : {{ vue.description }}
|
||||||
|
# Statetype : hard
|
||||||
|
#
|
||||||
|
# GENERE par Set-OPS (role serveur_icingaweb2, vue « {{ bpm_vue }} »). Ne pas editer :
|
||||||
|
# modifier le catalogue (serveur_icingaweb2_bpm_services) ou le `metier:` des sondes.
|
||||||
|
###################################
|
||||||
|
|
||||||
|
{% set rang = namespace(v=0) -%}
|
||||||
|
{% for s in services -%}
|
||||||
|
{% set controles = membres[s.cle] -%}
|
||||||
|
{% if s.par_machine | default(false) -%}
|
||||||
|
{% for hote in controles | map("first") | unique | sort -%}
|
||||||
|
machine_{{ s.cle }}_{{ hote }} = {{ controles | selectattr("0", "equalto", hote) | map("join", ";") | join(" & ") }}
|
||||||
|
display 0;machine_{{ s.cle }}_{{ hote }};{{ hote }}
|
||||||
|
{% endfor -%}
|
||||||
|
svc_{{ s.cle }} = {% for hote in controles | map("first") | unique | sort %}machine_{{ s.cle }}_{{ hote }}{{ " & " if not loop.last }}{% endfor %}
|
||||||
|
|
||||||
|
{% else -%}
|
||||||
|
svc_{{ s.cle }} = {{ controles | map("join", ";") | join(" & ") }}
|
||||||
|
{% endif -%}
|
||||||
|
{% if regrouper and s.fondation | default(false) -%}
|
||||||
|
display 0;svc_{{ s.cle }};{{ s.titre }}
|
||||||
|
{% else -%}
|
||||||
|
{% set rang.v = rang.v + 1 -%}
|
||||||
|
display {{ rang.v }};svc_{{ s.cle }};{{ s.titre }}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor -%}
|
||||||
|
{% set fondations = services | selectattr("fondation", "defined") | selectattr("fondation") | list -%}
|
||||||
|
{% if regrouper and fondations | length > 0 -%}
|
||||||
|
fondations = {{ fondations | map(attribute="cle") | map("regex_replace", "^", "svc_") | join(" & ") }}
|
||||||
|
display {{ rang.v + 1 }};fondations;Fondations techniques
|
||||||
|
{% endif -%}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
; Géré par Set-OPS (rôle serveur_icingaweb2). Ne pas éditer à la main.
|
; Géré par Set-OPS (rôle serveur_icingaweb2). Ne pas éditer à la main.
|
||||||
[global]
|
[global]
|
||||||
show_stacktraces = "0"
|
show_stacktraces = "0"
|
||||||
{% if serveur_icingaweb2_auth == 'db' %}
|
; LA CONSOLE RANGE SON PROPRE ETAT DANS SA PROPRE BASE (2026-09-15 au site, 2026-10-03
|
||||||
; LA CONSOLE RANGE SON PROPRE ETAT DANS SA PROPRE BASE (2026-09-15).
|
; partout : les locataires en SSO etaient restes en `ini`, avec la premiere erreur ci-dessous).
|
||||||
;
|
;
|
||||||
; `config_backend = "ini"` laissait les preferences en fichiers, et c'etait tenable — mais
|
; `config_backend = "ini"` laissait les preferences en fichiers, et c'etait tenable — mais
|
||||||
; le cadre de MIGRATION d'Icinga Web 2, lui, veut une instance de base quoi qu'il arrive.
|
; le cadre de MIGRATION d'Icinga Web 2, lui, veut une instance de base quoi qu'il arrive.
|
||||||
|
|
@ -19,9 +19,6 @@ show_stacktraces = "0"
|
||||||
; d'un navigateur a l'autre, ce que des fichiers locaux ne font pas.
|
; d'un navigateur a l'autre, ce que des fichiers locaux ne font pas.
|
||||||
config_backend = "db"
|
config_backend = "db"
|
||||||
config_resource = "icingaweb_db"
|
config_resource = "icingaweb_db"
|
||||||
{% else %}
|
|
||||||
config_backend = "ini"
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
[logging]
|
[logging]
|
||||||
log = "syslog"
|
log = "syslog"
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,10 @@ charset = "UTF8"
|
||||||
ssl_mode = "{{ serveur_icingaweb2_db_sslmode }}"
|
ssl_mode = "{{ serveur_icingaweb2_db_sslmode }}"
|
||||||
ssl_ca = "{{ serveur_icingaweb2_db_sslrootcert }}"
|
ssl_ca = "{{ serveur_icingaweb2_db_sslrootcert }}"
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if serveur_icingaweb2_auth == 'db' %}
|
|
||||||
|
|
||||||
; LA BASE DES COMPTES DE LA CONSOLE — distincte d'`icingadb`, qui est celle du MOTEUR.
|
; LA BASE DE LA CONSOLE — distincte d'`icingadb`, qui est celle du MOTEUR. Dans tous les
|
||||||
; Meler les deux ferait disparaitre les comptes au premier passage des migrations du
|
; modes : comptes en mode `db`, preferences et migrations partout. Meler les deux bases
|
||||||
; moteur, sans que personne n'ait touche aux comptes.
|
; ferait disparaitre les comptes au premier passage des migrations du moteur.
|
||||||
[icingaweb_db]
|
[icingaweb_db]
|
||||||
type = "db"
|
type = "db"
|
||||||
db = "pgsql"
|
db = "pgsql"
|
||||||
|
|
@ -36,7 +35,6 @@ charset = "UTF8"
|
||||||
ssl_mode = "{{ serveur_icingaweb2_db_sslmode }}"
|
ssl_mode = "{{ serveur_icingaweb2_db_sslmode }}"
|
||||||
ssl_ca = "{{ serveur_icingaweb2_db_sslrootcert }}"
|
ssl_ca = "{{ serveur_icingaweb2_db_sslrootcert }}"
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
|
||||||
{% if serveur_icingaweb2_auth not in ('db',) %}
|
{% if serveur_icingaweb2_auth not in ('db',) %}
|
||||||
|
|
||||||
[icingaweb_ldap]
|
[icingaweb_ldap]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
---
|
---
|
||||||
# Distribution Keycloak (Quarkus). Verifier/ajuster la version sur keycloak.org/downloads.
|
# Distribution Keycloak (Quarkus). Verifier/ajuster la version sur keycloak.org/downloads.
|
||||||
serveur_keycloak_version: "26.7.1"
|
# 26.7.5 (2026-10-03) : quatre versions de correctifs apres 26.7.1, chacune corrigeant
|
||||||
|
# plusieurs CVE. La branche 26.8 existe ; on reste sur la correction de la meme branche.
|
||||||
|
serveur_keycloak_version: "26.7.5"
|
||||||
serveur_keycloak_url: "https://github.com/keycloak/keycloak/releases/download/{{ serveur_keycloak_version }}/keycloak-{{ serveur_keycloak_version }}.tar.gz"
|
serveur_keycloak_url: "https://github.com/keycloak/keycloak/releases/download/{{ serveur_keycloak_version }}/keycloak-{{ serveur_keycloak_version }}.tar.gz"
|
||||||
serveur_keycloak_java_paquet: "default-jre-headless"
|
serveur_keycloak_java_paquet: "default-jre-headless"
|
||||||
|
|
||||||
|
|
@ -204,7 +206,7 @@ serveur_keycloak_cache_local: "{{ setops_cache_artefacts | default(lookup('env',
|
||||||
# d'authenticite compte le plus.
|
# d'authenticite compte le plus.
|
||||||
#
|
#
|
||||||
# Continuite constatee le 2026-08-10 : cette meme cle a signe 26.0.7 (la version alors en
|
# Continuite constatee le 2026-08-10 : cette meme cle a signe 26.0.7 (la version alors en
|
||||||
# production), 26.3.0, 26.6.2 et 26.7.1.
|
# production), 26.3.0, 26.6.2 et 26.7.1 — puis 26.7.5, verifiee le 2026-10-03.
|
||||||
#
|
#
|
||||||
# ANCRE (2026-08-11) — l'empreinte est PUBLIEE PAR LE PROJET sur https://www.keycloak.org/keys,
|
# ANCRE (2026-08-11) — l'empreinte est PUBLIEE PAR LE PROJET sur https://www.keycloak.org/keys,
|
||||||
# sous l'UID « Keycloak Bot <keycloak.bot@gmail.com> ». Ce canal (keycloak.org) est
|
# sous l'UID « Keycloak Bot <keycloak.bot@gmail.com> ». Ce canal (keycloak.org) est
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
# Supervision derivee du role. Voir docs/supervision-conception.md.
|
# Supervision derivee du role. Voir docs/supervision-conception.md.
|
||||||
sondes:
|
sondes:
|
||||||
- nom: identite
|
- nom: identite
|
||||||
|
metier: connexion
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
raison: Le realm sert-il sa decouverte OIDC, et Keycloak ATTEINT-IL l'annuaire ? La decouverte est la
|
raison: Le realm sert-il sa decouverte OIDC, et Keycloak ATTEINT-IL l'annuaire ? La decouverte est la
|
||||||
porte par laquelle tout le SSO entre ; la federation LDAP est ce qui la rend utile. Un Keycloak dont
|
porte par laquelle tout le SSO entre ; la federation LDAP est ce qui la rend utile. Un Keycloak dont
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue