Compare commits
171 commits
fix_client
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 479ec3cc7d | |||
| 9a4b63544e | |||
| 3e2846cc5f | |||
| 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 | |||
| fbd4094d3f | |||
| 2bbe12299f | |||
| 5404c947d8 | |||
| 1653319ef1 | |||
| 8ac7909d49 | |||
| 4e07b52291 | |||
| ca1f1557c4 | |||
| 9e6f33d1cf | |||
| 6b6a56ab3f | |||
| b938897e17 | |||
| 8f699eb3ce | |||
| cf08e51c38 | |||
| 3063bd4bf3 | |||
| 5c638675cb | |||
| 91dc90f22b | |||
| 974fabc40a | |||
| b1c4775afe | |||
| bcf62bafe7 | |||
| 6edb8ac7d2 | |||
| 79fcff3274 | |||
| a401481ae9 | |||
| 22fffdbb6f | |||
| 40b38467e8 | |||
| 5e7534d87f | |||
| d243773b45 | |||
| aac2d35e06 | |||
| 67fd3ab6b7 | |||
| 8804d67dbf | |||
| d673f97ac9 | |||
| 58432fa6e1 | |||
| 35831608d0 | |||
| 4ce296f3d5 | |||
| 3676e69cd9 | |||
| 4f5a0b5696 | |||
| 0156596f01 | |||
| ffc171a2cb | |||
| 5f52dcb2b7 | |||
| 4855cda294 | |||
| 2bd761b85f | |||
| 415189ceff | |||
| 0551c4b4de | |||
| f5adffb0de | |||
| 185b000c60 | |||
| 831c7d5cf4 | |||
| 51b8165221 | |||
| fe9d47c4a6 | |||
| 3a379f6344 | |||
| 478aab3eb8 | |||
| badbcd3971 | |||
| fde604c2a5 | |||
| e1a4dc62bb | |||
| 6a7188290f | |||
| c0f610be33 |
281 changed files with 17697 additions and 843 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -9,6 +9,9 @@ facts_cache/
|
|||
**/vault.yml
|
||||
# Underlay = fabric physique de l'operateur (cluster-global) ; gabarit public seul.
|
||||
/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
|
||||
*.pem
|
||||
*.key
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ Si `ansible-lint` n’est pas disponible, le signaler clairement. Ne pas invente
|
|||
## Écrire, puis relire (D-68)
|
||||
|
||||
`--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
|
||||
dépôt annonce.**
|
||||
|
||||
|
|
|
|||
3503
CHANGELOG.md
3503
CHANGELOG.md
File diff suppressed because it is too large
Load diff
233
Makefile
233
Makefile
|
|
@ -21,7 +21,16 @@ export SETOPS_INSTANCE ?= instance
|
|||
#
|
||||
# `?=` : 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.
|
||||
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
|
||||
# instance ». Détection rétro-compatible : principal > production > lab.
|
||||
# Surchargeable : make … SETOPS_INVENTAIRE=chemin/hosts.yml
|
||||
|
|
@ -308,12 +317,26 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire)
|
|||
python3 scripts/tests/test_inventory_host.py
|
||||
python3 scripts/tests/test_raser.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_gui_intrants.py
|
||||
python3 scripts/tests/test_runbooks.py
|
||||
python3 scripts/tests/test_portee_console.py
|
||||
python3 scripts/tests/test_devis_placement.py
|
||||
python3 scripts/tests/test_sonder.py
|
||||
python3 scripts/tests/test_fraicheur_icinga.py
|
||||
python3 scripts/tests/test_sondes_conditionnelles.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
|
||||
# journee, tous dus a l'instrument : `curl` et `bash /dev/tcp` ecrasent « la machine
|
||||
|
|
@ -533,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)
|
||||
|
||||
appliquer: ansible-runtime ## Applique un groupe a la flotte — GROUPE=<groupe>
|
||||
@if [[ -z "$(GROUPE)" ]]; then \
|
||||
printf '%s\n' 'Refus: relancer avec GROUPE=nom_groupe.'; \
|
||||
@# `-z "$(GROUPE)"` NE POUVAIT JAMAIS SE DECLENCHER (2026-10-03) : `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; \
|
||||
fi
|
||||
@if [[ ! -f "$(DOSSIER_PLAYBOOKS_GROUPES)/$(GROUPE).yml" ]]; then \
|
||||
|
|
@ -542,7 +569,9 @@ appliquer: ansible-runtime ## Applique un groupe a la flotte — GROUPE=<groupe>
|
|||
exit 2; \
|
||||
fi
|
||||
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
|
||||
# (`_verifier-acces-hote`). Depuis que `creer-vm` ne guette plus le SSH — il prouve la
|
||||
|
|
@ -584,10 +613,17 @@ site: ansible-runtime ## Regenere playbooks/site.yml depuis les couches et le gr
|
|||
site-verifier: ## Verifie que playbooks/site.yml correspond aux couches declarees
|
||||
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)
|
||||
python3 scripts/exporter_cles.py --recenser
|
||||
|
||||
voutes-recenser: ## Montre les voutes de ce poste (gitignorees : sur aucune forge), sans rien ecrire
|
||||
python3 scripts/exporter_voutes.py --recenser
|
||||
|
||||
voutes-exporter: ## Sort les voutes (deja chiffrees) vers un support, relues — VERS=<repertoire> NOM=<base>
|
||||
@if [ -z "$(VERS)" ]; then printf '%s\n' 'Refus: VERS=<repertoire du support> requis.'; exit 2; fi
|
||||
python3 scripts/exporter_voutes.py --vers "$(VERS)" $(if $(NOM),--nom "$(NOM)")
|
||||
|
||||
cles-exporter: ## Sort les cles du poste, chiffrees et relues — VERS=<repertoire>
|
||||
@# A LANCER SOI-MEME, PAS PAR UN AGENT. `gpg` demande une phrase de passe : elle ne
|
||||
@# doit passer ni par un journal, ni par le contexte d'un assistant. Taper la commande
|
||||
|
|
@ -597,7 +633,7 @@ cles-exporter: ## Sort les cles du poste, chiffrees et relues — VERS=<repertoi
|
|||
printf 'Ex. make cles-exporter VERS=/media/danallaire/CLE\n'; exit 2; fi
|
||||
python3 scripts/exporter_cles.py --vers "$(VERS)"
|
||||
|
||||
.PHONY: cles-recenser cles-exporter
|
||||
.PHONY: cles-recenser cles-exporter voutes-recenser voutes-exporter
|
||||
depot-hors-site: ## Copie le depot de sauvegarde du site HORS du site — VERS=<repertoire>
|
||||
@# Le depot heberge la racine de l'AC, la forge du genome, et l'etat de CHAQUE
|
||||
@# locataire — sur une seule machine, dans un seul batiment. La copie est opaque
|
||||
|
|
@ -689,8 +725,8 @@ devis-opnsense: ansible-runtime ## Devis OPNsense (frontiere nord/sud), derive d
|
|||
python3 scripts/devis_opnsense.py $(if $(JSON),--json,)
|
||||
|
||||
.PHONY: proxmox-fw-plan proxmox-fw-appliquer
|
||||
proxmox-fw-plan: ansible-runtime ## Ecart entre le pare-feu est-ouest Proxmox et son devis (aucune ecriture)
|
||||
python3 scripts/appliquer_proxmox_fw.py
|
||||
proxmox-fw-plan: ansible-runtime ## Ecart entre le pare-feu est-ouest Proxmox et son devis (aucune ecriture) — ARGS="--objets-seulement" ou "--vm <vmid>"
|
||||
python3 scripts/appliquer_proxmox_fw.py $(ARGS)
|
||||
|
||||
proxmox-fw-appliquer: ansible-runtime ## Reconcilie le pare-feu est-ouest : IPSets, groupes, affectations. CONFIRMER=true
|
||||
@if [[ "$(CONFIRMER)" != "true" ]]; then \
|
||||
|
|
@ -698,11 +734,23 @@ proxmox-fw-appliquer: ansible-runtime ## Reconcilie le pare-feu est-ouest : IPSe
|
|||
"et RETIRE ce qui est perime. Elle n'active JAMAIS le pare-feu du datacenter." \
|
||||
"Relire d'abord 'make proxmox-fw-plan', puis: make proxmox-fw-appliquer CONFIRMER=true"; \
|
||||
exit 2; fi
|
||||
CONFIRMER=true python3 scripts/appliquer_proxmox_fw.py
|
||||
CONFIRMER=true python3 scripts/appliquer_proxmox_fw.py $(ARGS)
|
||||
|
||||
proxmox-fw-eprouver: ansible-runtime ## Verifie qu'activer le pare-feu d'UNE VM ne coupe rien (aucune ecriture) — TENANT=<OPS-X> HOTE=<vm>
|
||||
@if [ -z "$(TENANT)" ] || [ -z "$(HOTE)" ]; then printf '%s\n' 'Refus: TENANT=<OPS-X> et HOTE=<vm> requis.'; exit 2; fi
|
||||
python3 scripts/eprouver_parefeu.py --instance "$(TENANT)" --hote "$(HOTE)" --plan
|
||||
|
||||
proxmox-fw-activer-vm: ansible-runtime ## Active le pare-feu d'UNE VM en le prouvant avant/apres — TENANT= HOTE= CONFIRMER=true
|
||||
@# UNE VM A LA FOIS, PREUVE COMPRISE (2026-09-28). Refuse si un flux reel echappe aux
|
||||
@# regles ou si un flux echoue deja ; rejoue la matrice apres et lit Icinga.
|
||||
@if [ -z "$(TENANT)" ] || [ -z "$(HOTE)" ]; then printf '%s\n' 'Refus: TENANT=<OPS-X> et HOTE=<vm> requis.'; exit 2; fi
|
||||
@if [[ "$(CONFIRMER)" != "true" ]]; then printf '%s\n' "Refus: active le pare-feu de $(HOTE) en REJECT. Voir d'abord 'make proxmox-fw-eprouver', puis CONFIRMER=true."; exit 2; fi
|
||||
python3 scripts/eprouver_parefeu.py --instance "$(TENANT)" --hote "$(HOTE)" --activer
|
||||
|
||||
.PHONY: proxmox-fw-eprouver proxmox-fw-activer-vm
|
||||
.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)
|
||||
python3 scripts/devis_placement.py
|
||||
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 $(if $(TENANT),--locataire $(TENANT))
|
||||
|
||||
sdn-plan: ansible-runtime ## Ecart entre le SDN EVPN (+ sortie des VRF) et son devis (aucune ecriture)
|
||||
python3 scripts/appliquer_sdn.py
|
||||
|
|
@ -825,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)
|
||||
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
|
||||
vpn-admin-plan: ## Acces WireGuard des admins : ce que la frontiere porte face au plan (aucune ecriture)
|
||||
python3 scripts/vpn_admin.py plan
|
||||
|
|
@ -874,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
|
||||
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
|
||||
schema: ## Regenere docs/audit/schema-plan.json depuis les registres et les validateurs
|
||||
python3 scripts/schema_plan.py
|
||||
|
|
@ -1058,14 +1150,23 @@ define VAULT_UNE_FOIS
|
|||
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; \
|
||||
if [[ "$(CONFIRMER)" != "true" ]]; then \
|
||||
printf '%s\n' 'Refus: creation de TOUTES les VM actives du plan (clone Proxmox).'; \
|
||||
printf '%s\n' 'Relancer avec CONFIRMER=true.'; \
|
||||
exit 2; \
|
||||
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; \
|
||||
par="$(PARALLELE)"; par="$${par:-4}"; \
|
||||
tmp="$$(mktemp -d)"; trap 'rm -rf "$$tmp"' EXIT; \
|
||||
|
|
@ -1074,7 +1175,7 @@ flotte-creer: _instance-requise ## Cree les VM manquantes de la flotte depuis le
|
|||
n=0; \
|
||||
for h in $$hotes; do \
|
||||
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)); \
|
||||
if (( n >= par )); then wait -n || true; n=$$((n-1)); fi; \
|
||||
done; \
|
||||
|
|
@ -1089,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; \
|
||||
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 -----------------------------------------------------
|
||||
#
|
||||
# UN SITE N'EST PAS UN PLAN. Un tenant se derive de son `index` ; un site n'en a pas et
|
||||
|
|
@ -1110,6 +1218,10 @@ site-inventaire: ## L'inventaire dynamique du site, tel qu'Ansible le voit
|
|||
|
||||
.PHONY: site-appliquer
|
||||
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
|
||||
@# 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
|
||||
|
|
@ -1248,6 +1360,13 @@ genome-etat: ## Ce que la forge du site porte, face au poste — ne corrige rien
|
|||
playbooks/maintenance/genome_etat.yml "$${supp[@]}" $(ARGS)
|
||||
|
||||
.PHONY: genome-pousser
|
||||
publier: ## Publie le genome d'un geste : eregion PUIS la forge du site, et le verifie — DEPOT=<nom> pour un seul
|
||||
@# LE RETARD VIENT D'UN GESTE QU'ON OUBLIE (2026-09-28) : un `git push` vers eregion ne
|
||||
@# met pas la forge du site a jour, et c'est elle qui fait autorite. Refuse si eregion
|
||||
@# porte des commits absents du poste (une fusion acceptee) — ne pas les ecraser.
|
||||
python3 scripts/publier.py $(if $(DEPOT),--depot "$(DEPOT)")
|
||||
|
||||
.PHONY: publier
|
||||
genome-pousser: ## Le runner du site pousse le genome sur la forge du site — DEPOT=<nom> pour un seul
|
||||
@set -e; \
|
||||
voute="$$(dirname "$$(readlink -f underlay.yml)")/underlay.vault.yml"; \
|
||||
|
|
@ -1343,8 +1462,8 @@ _attendre-flotte: ansible-runtime
|
|||
printf 'Flotte joignable.\n'
|
||||
|
||||
.PHONY: reconstruire
|
||||
raser: ansible-runtime _instance-requise ## DESTRUCTIF : detruit les VM derivees du plan — exige CONFIRMER=true ET INSTANCE=<nom>
|
||||
@python3 scripts/raser.py $(if $(INSTANCE),--instance $(INSTANCE)) $(if $(HOTE),--hote $(HOTE)) $(if $(filter true,$(CONFIRMER)),--confirmer)
|
||||
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 $(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/`
|
||||
# est VIDE et le socle deploie nftables en `policy drop` SANS AUCUNE REGLE DERIVEE. La
|
||||
|
|
@ -1365,8 +1484,70 @@ reconstruire: _instance-requise ## Reconstruit un ecosysteme depuis zero : VM pu
|
|||
$(MAKE) flux; \
|
||||
$(MAKE) flotte-creer CONFIRMER=true; \
|
||||
$(MAKE) _attendre-flotte; \
|
||||
$(MAKE) _amorcer-socle; \
|
||||
$(MAKE) deployer-tout CONFIRMER=true
|
||||
$(MAKE) monter-flotte 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).
|
||||
# Cree toutes les VM puis deploie tout, en une commande. Garde CONFIRMER=true.
|
||||
|
|
@ -1391,8 +1572,12 @@ _amorcer-socle: ansible-runtime _instance-requise
|
|||
myDay: reconstruire ## Alias strict de `reconstruire` (meme cible, memes gardes)
|
||||
|
||||
deployer-groupe: ## Deploie un seul groupe sur toute la flotte — GROUPE=<groupe>
|
||||
@if [[ -z "$(GROUPE)" ]]; then \
|
||||
printf '%s\n' 'Refus: relancer avec GROUPE=nom_groupe.'; \
|
||||
@# `-z "$(GROUPE)"` NE POUVAIT JAMAIS SE DECLENCHER (2026-10-03) : `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; \
|
||||
fi
|
||||
$(MAKE) appliquer GROUPE="$(GROUPE)"
|
||||
|
|
@ -1460,7 +1645,7 @@ cloner-vm: ansible-runtime ## Clone une VM depuis le gabarit dore — HOTE=<nom>
|
|||
-e proxmox_clone_nom="$(HOTE)" \
|
||||
-e proxmox_clone_vmid="$(VMID)" \
|
||||
-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 "$(NOEUD_PROXMOX)" ]] && extra_vars+=( -e proxmox_clone_noeud="$(NOEUD_PROXMOX)" ); \
|
||||
|
|
@ -1503,13 +1688,13 @@ cloner-vm: ansible-runtime ## Clone une VM depuis le gabarit dore — HOTE=<nom>
|
|||
fi; \
|
||||
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; \
|
||||
if [[ -z "$(HOTE)" ]]; then \
|
||||
printf '%s\n' 'Refus: relancer avec HOTE=nom_hote (declare dans le plan).'; \
|
||||
exit 2; \
|
||||
fi; \
|
||||
params="$$(python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) parametres-proxmox --hote $(HOTE))"; \
|
||||
params="$$($(PARAMS_CLONAGE) $(HOTE))"; \
|
||||
eval "$$params"; \
|
||||
export SETOPS_CLES_AMORCAGE; \
|
||||
$(MAKE) cloner-vm \
|
||||
|
|
@ -1550,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
|
||||
@# et dpkg se guettent juste avant de configurer, par celui qui va configurer.
|
||||
@if [[ "$(ATTENDRE)" != "false" ]]; then \
|
||||
params="$$(python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) parametres-proxmox --hote $(HOTE))"; \
|
||||
params="$$($(PARAMS_CLONAGE) $(HOTE))"; \
|
||||
eval "$$params"; \
|
||||
python3 scripts/attendre_materialisation.py \
|
||||
--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).",
|
||||
"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": {
|
||||
"type": "array",
|
||||
"title": "Secondaires",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
# 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
|
||||
source: 4e0b9ca
|
||||
date: 2026-09-21
|
||||
source: e9215a2
|
||||
date: 2026-10-07
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ README de rôles). Cette page comble ces deux trous.
|
|||
|---|---|---|
|
||||
| rôles | 69 | `roles/*/` |
|
||||
| README de rôles | 69 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
|
||||
| documents | 45 | `docs/*.md` |
|
||||
| pièces d'audit | 48 | `docs/audit/*` |
|
||||
| documents | 46 | `docs/*.md` |
|
||||
| pièces d'audit | 51 | `docs/audit/*` |
|
||||
| unités de wiki | 27 | `wiki/*.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 |
|
||||
|
|
@ -36,7 +36,7 @@ README de rôles). Cette page comble ces deux trous.
|
|||
| Sujet | Documents |
|
||||
|---|---|
|
||||
| **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à**) |
|
||||
| **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` |
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@ L'ordre ci-dessous privilégie les dépendances structurantes avant les applicat
|
|||
> besoin de voir. Les phases ci-dessous gardent leur numérotation, qui dit une **parenté
|
||||
> logique** ; l'ordre exécutable, lui, est dans `docs/couches-deploiement.yml` (D-86).
|
||||
>
|
||||
> Ce qui reste tard, et à dessein : la **console** (`icingaweb2`, `oauth2_proxy`), qui
|
||||
> Ce qui reste tard, et à dessein : la **vigie** (`icingaweb2`, `oauth2_proxy`), qui
|
||||
> réclame LDAP et Keycloak. L'interface humaine peut attendre ; la mesure, non.
|
||||
|
||||
### Phase 1 - Fondations transversales
|
||||
|
|
|
|||
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.
|
||||
|
|
@ -55,8 +55,8 @@ sont les seules vérifiables.
|
|||
|
||||
| # | Décision | Pourquoi | Détail | Garde |
|
||||
|---|---|---|---|---|
|
||||
| **D-82** | **Patient 0 n'est le parent de personne.** Il est la **mise en œuvre de référence** du modèle `origine` — le plus petit écosystème complet — et un pair de la famille du génome, pas sa racine | trois faits l'ont retiré un par un : D-81 a donné l'autorité du génome à la forge du SITE (son dernier lecteur corrigé le 2026-08-26, Technolibre le 08-31) ; le dénominateur commun vit dans les modèles depuis le 08-24 ; et **l'ancêtre était locataire de son enfant** — index 29 sur la fabric de `SITE-Chezlepro`, qui descend de lui. Ce qu'il devait éliminer — le SPOF `eregion`, hors flotte — n'a PAS été éliminé mais **promu** : le poste y pousse, la forge du site en tire. Cette dette appartient désormais au SITE, et la nommer est le minimum : *un objectif qu'on abandonne sans le dire devient un objectif qu'on croit atteint* | `OPS-Patient0/README.md`, `docs/filiation-emancipation.md` | — |
|
||||
| **D-83** | **Patient 0 a été retiré** — ses machines n'existent plus (constaté le 2026-09-06) | D-82 lui avait laissé une raison d'être : la mise en œuvre de référence du modèle `origine`, et un **témoin** de plus du génome. Le retrait solde la première et **abaisse la seconde de trois copies vivantes à deux** (`eregion`, la forge du site). Ce qu'il devait éliminer — le SPOF `eregion` — reste entier, et sans lui il n'y a plus de miroir indépendant pour l'absorber. **Son plan reste sur disque et la fédération lui réserve toujours l'index 29** : tant que ce n'est pas tranché, le site ouvre SSH, apt, DNS et HTTPS à `10.29.0.0/16` — un périmètre vide | `OPS-Patient0/`, `SITE-Chezlepro/flux-genere/` | **P21** (index), **P23** |
|
||||
| **D-82** | **Patient 0 n'est le parent de personne.** Il est la **mise en œuvre de référence** du modèle `origine` — le plus petit écosystème complet — et un pair de la famille du génome, pas sa racine | trois faits l'ont retiré un par un : D-81 a donné l'autorité du génome à la forge du SITE (son dernier lecteur corrigé le 2026-08-26, Technolibre le 08-31) ; le dénominateur commun vit dans les modèles depuis le 08-24 ; et **l'ancêtre était locataire de son enfant** — index 29 sur la fabric de `SITE-Chezlepro`, qui descend de lui. `eregion` (`forge.alliance-boreale.ca`) n'est PAS sur le chemin du génome : le poste porte les commits en bundle au runner du site (`make genome-pousser`), qui pousse sur sa forge. `eregion` est une forge héritée, porte publique des contributions, qui ne fera jamais partie de Set-OPS — la redondance vient d'une forge par site, chacune inséminée du génome *(corrigé le 2026-09-28 : cette ligne l'avait dite « SPOF promu », sans mesurer le chemin)* | `OPS-Patient0/README.md`, `docs/filiation-emancipation.md` | — |
|
||||
| **D-83** | **Patient 0 a été retiré** — ses machines n'existent plus (constaté le 2026-09-06) | D-82 lui avait laissé une raison d'être : la mise en œuvre de référence du modèle `origine`, et un **témoin** de plus du génome. Le retrait solde les deux : l'une vit dans le modèle `origine`, l'autre revient aux forges de site. **Son plan a été effacé le 2026-09-27** : l'index 29 est libéré, et le site n'ouvre plus rien à `10.29.0.0/16` | `SITE-Chezlepro/underlay.yml` (`tenants`), `SITE-Chezlepro/flux-genere/` | **P21** (index), **P23** |
|
||||
| **D-84** | **Le plan de contrôle reste gelé — c'est la CARTE DES SEUILS qui était fausse** | La question « et si on retirait le gel ? » a mis à l'épreuve les cinq seuils de `positionnement.md`, et deux ne tenaient pas. **RBAC** : couvert depuis que trois classes d'acteurs aux pouvoirs disjoints existent — poste, runner de site, runners de tenant — séparés **cryptographiquement** (une voûte, une clé, 2026-08-28) et non par une table de permissions qu'une faille applicative contournerait ; adopter AWX pour ce besoin serait **régresser**. **IPAM** : sans objet par construction — rien ne s'alloue, tout dérive du seed, et P20/P21/P23/P28/P33 tiennent déjà ce qu'un IPAM vérifierait *a posteriori*. Les deux lignes sont retirées du tableau : les garder aurait fait adopter un outil pour un besoin déjà rempli. **Et un seuil manquait** — l'**émancipation** : le GUI est mono-utilisateur (`127.0.0.1` + jeton), or la trajectoire mène à plusieurs humains aux portées disjointes, sur des machines qui ne sont pas les nôtres. Ce seuil n'appelle pas AWX, il appelle une décision non prise. *Un seuil qu'on ne nomme pas est un seuil qu'on franchit sans le voir.* Corollaire consigné : le gel porte sur les **fonctions**, jamais sur les **vues** — montrer à l'écran ce que le moteur sait déjà ne franchit aucun seuil | `positionnement.md` §3, §4, §5 | — |
|
||||
| **D-85** | **cloud-init naît avec la VM et ne lui survit pas** | cloud-init n'est pas un logiciel d'installation : c'est une **source de vérité externe**, qui se réveille à *chaque* démarrage et relit le lecteur attaché par l'hyperviseur — lequel peut redéfinir comptes, clés SSH autorisées, mots de passe et réseau. Sur une machine que le plan possède, c'est un **second maître** : le plan ne le décrit pas, `make valider` ne le mesure pas, et il parle en premier. Sa tâche est pourtant finie à la première seconde — c'est parce qu'il a **réussi** à poser l'adresse et les clés qu'Ansible a pu entrer. **Trois moitiés, qui se défont séparément** : le **gabarit** le garde (sans lui un clone ne naît pas — P56) ; le **socle** ne l'installe plus (le garder produisait un va-et-vient à chaque déploiement : le socle installe, le durcissement retire, deux `changed` par passage) ; le **durcissement** le retire (`cloud_init_retrait`, en dernier). **Ce qui rend le retrait sûr est mesuré, pas supposé** (2026-09-09, `obs-01`) : `/etc/network/interfaces.d/50-cloud-init` n'appartient à aucun paquet — `dpkg -S` ne le trouve pas — et le `postrm` ne le nomme jamais, même en `purge`. L'adresse survit. Le rôle le **vérifie** malgré tout, avant et après : une VM qui perd ce fichier ne se plaint pas, elle repart sans adresse et plus personne ne peut entrer pour le constater. **⚠ CE QUE CETTE DÉCISION NE FERME PAS — et il faut le dire, sinon elle se lit comme une émancipation qu'elle n'est pas.** Retirer cloud-init **n'ôte aucun pouvoir à l'hébergeur**. `qemu-guest-agent` est au gabarit (P56 : il doit y être — c'est par lui que `creer-vm` confirme la matérialisation sans entrer chez le tenant), et l'API Proxmox expose sur son dos, sur toute VM vivante de la flotte, un pouvoir **strictement plus grand** que le lecteur cloud-init : `exec`, `file-write`, `file-read`, `set-user-password`, `shutdown` (relevé le 2026-09-09 sur `edge-mta-01`, jeton d'API du site). Ce que D-85 ferme est donc **précis et étroit** : (a) une réapplication **automatique, à chaque démarrage**, depuis un support que le plan ne possède pas et qu'aucune preuve ne lit ; (b) le code de cloud-init lui-même — un interpréteur Python complet, exécuté en root au démarrage, et ses ~29 dépendances. Elle ne ferme **pas** la mainmise de l'hyperviseur sur ses invités : celle-là est une propriété de la virtualisation, pas de cloud-init, et elle appelle sa propre décision — non prise. **Le seuil où le remplacer deviendrait juste** : le jour où une première seconde ne peut plus être amorcée par Proxmox (autre hyperviseur, métal nu, hébergeur sans API), le chemin par l'agent invite cesse d'être une réimplémentation d'un standard — que `positionnement.md` interdit — et devient **le chemin portable**. Tant que ce seuil n'est pas atteint, écrire soi-même l'amorçage serait échanger un standard éprouvé contre du code maison au moment le plus fragile, dont le mode de panne est le pire : une VM injoignable | `roles/cloud_init_retrait/`, `serveur_durci.yml`, `positionnement.md` | **P63** |
|
||||
| **D-86** | **La supervision se déploie juste après la PKI, pas à la fin** | L'observabilité (`prometheus`, `loki`, `grafana`) et le **moteur** de supervision (`icinga`) passent en couche 4, immédiatement après `client_pki` ; les agents qui les nourrissent (`client_metrique`, `client_journal`, `client_sante`) en couche 5. **Le raisonnement** : ce qui se déploie ensuite l'est *sous l'œil* de la supervision — une unité qui casse se voit à la minute, pas à la fin. Une **reconstruction depuis zéro** est précisément le moment où l'on a le plus besoin de voir, et c'était le seul moment où l'on ne voyait rien. **Déplacer les serveurs sans les agents n'aurait rien changé** : ce sont les agents qui rapportent, et ils étaient en dernière couche. **Ce que ça a coûté en dépendances** : `serveur_postgresql` monte aussi (il n'exige rien lui-même, et `icinga` l'exige). **Ce qui reste tard, à dessein** : `icingaweb2` et `oauth2_proxy` réclament LDAP et Keycloak — c'est la CONSOLE, pas la mesure. L'interface humaine peut attendre. **Limite dite franchement** : les *notifications* dépendent de `client_smtp`, encore en dernière couche — pendant une reconstruction, l'état est mesuré et consultable, mais rien ne part par courriel avant la fin. **CE QUI REND CE DÉPLACEMENT POSSIBLE**, et qui n'est pas un détail : le DNS (`powerdns`, `resolveur`) reste en couche 6, donc *après* la supervision. Or `icinga` joint sa base par un **nom** (`data-sql-01.chezlepro.internal`), et `client_sante` pousse vers un **nom**. Ça tient parce que le **plancher `/etc/hosts`**, posé dès la couche 1 par `hosts_statiques`, porte déjà les 34 entrées de l'écosystème — vérifié. C'est exactement ce pour quoi il existe : *« il ne s'installe pas, il rend installable »*. Sans lui, cette décision serait impossible. **P08** valide l'ordre : aucune arête en arrière | `docs/couches-deploiement.yml`, `catalogue-services.md` §Ordre | **P08** |
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ make placement-plan # chaque VM est-elle là où le plan la met
|
|||
|
||||
## 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
|
||||
cohérent **avec lui-même** — que les handlers existent, que les intrants ont un
|
||||
propriétaire, que rien n'est codé en dur.
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ Jusqu'ici, le moteur a rencontré ce besoin **trois fois sans le nommer** :
|
|||
```
|
||||
client_artefacts_actif dérivé : « une source existe-t-elle chez moi ? »
|
||||
serveur_ops_forge_externe « je lis mon génome ailleurs »
|
||||
client_backup_cible patient 0 sauvegarde chez eregion — mutualisé, en production
|
||||
client_backup_cible « je sauvegarde chez le site » — mutualisé, en production
|
||||
```
|
||||
|
||||
Trois astuces, une seule notion. Ce document la déclare.
|
||||
|
|
@ -239,29 +239,16 @@ déclarer du tout.
|
|||
|
||||
## Qui est le parent ? — tranché le 2026-08-31 (D-82)
|
||||
|
||||
Le dilemme est resté ouvert trois jours. Les faits l'ont tranché plus que le raisonnement :
|
||||
**personne n'est le parent, et la forge du SITE est l'autorité.**
|
||||
**Personne. La forge du SITE fait autorité pour le génome** (D-81) ; toute autre copie est
|
||||
un miroir. Le dépôt l'avait suivi avant de le déclarer : `serveur_forge_site`, puis
|
||||
`serveur_cache_site`, puis `serveur_resolveur_site` — trois services prêtés par le site à
|
||||
ses locataires, un seul patron.
|
||||
|
||||
Trois issues avaient été posées. C'est la deuxième qui l'emporte, non parce qu'elle était
|
||||
la plus élégante, mais parce que les deux autres avaient cessé d'être disponibles :
|
||||
|
||||
- **patient 0 redevient la source** — impossible sans le rendre atteignable depuis *tout*
|
||||
site, alors qu'il vit dans la fabric d'un seul, en locataire de son propre descendant ;
|
||||
- **le SITE est la source** ✔ — c'est ce que D-81 avait déjà fait, et que le reste du dépôt
|
||||
a suivi sans qu'on le déclare : `serveur_forge_site`, puis `serveur_cache_site`, puis
|
||||
`serveur_resolveur_site`. Trois services prêtés, un seul patron ;
|
||||
- **une famille de pairs** — reste vraie *pour la redondance*. C'est ce que patient 0
|
||||
gardait : un miroir du génome, comme chaque écosystème. Il perdait le rang, pas la place.
|
||||
|
||||
> **Il a perdu la place aussi : ses machines n'existent plus (2026-09-06).** La famille du
|
||||
> génome compte donc **deux** copies vivantes au lieu de trois — `eregion` et la forge du
|
||||
> site. Or c'est précisément le raisonnement ci-dessus qui portait tout : *on n'échappe pas
|
||||
> à la boucle par la ruse, mais par le nombre.* Le nombre a baissé, et le SPOF que patient 0
|
||||
> devait absorber (`eregion`, hors flotte) est toujours là.
|
||||
|
||||
**Ce que ça ne règle pas.** Le point unique de défaillance que patient 0 devait éliminer —
|
||||
`eregion`, hors flotte — n'a pas disparu : il alimente maintenant l'autorité. La dette a
|
||||
changé de propriétaire, pas de nature. Elle appartient au SITE.
|
||||
**Par où le génome arrive.** Le poste porte les commits en bundle au runner du site
|
||||
(`make genome-pousser`), qui les pousse sur sa forge. Aucune autre forge n'est sur ce
|
||||
chemin. `eregion` (`forge.alliance-boreale.ca`) est une forge **héritée** : la porte
|
||||
publique des contributions, qui ne fera jamais partie de Set-OPS. La redondance ne vient
|
||||
pas d'elle mais du nombre de sites — une forge par site, chacune inséminée du génome.
|
||||
|
||||
## État — revu le 2026-09-06
|
||||
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ qu'on touche à son plan — c'est ce qui rend la portabilité possible.
|
|||
|
||||
> **Un tenant ne détient jamais un secret du monde physique.**
|
||||
|
||||
Chaque tenant a porté dans sa voûte le jeton d'API du cluster — patient 0 a dû le recopier
|
||||
pour exister. C'était exactement la faute des **neuf copies** de la résolution d'instance,
|
||||
Chaque tenant a porté dans sa voûte le jeton d'API du cluster — chaque nouveau tenant devait le
|
||||
recopier pour exister. C'était exactement la faute des **neuf copies** de la résolution d'instance,
|
||||
appliquée aux secrets : une valeur qui vit à N endroits finit par diverger, et on ne peut
|
||||
plus révoquer l'une sans révoquer les autres.
|
||||
|
||||
|
|
|
|||
|
|
@ -108,8 +108,11 @@ qui donne accès aux vues de statistiques **et à elles seules**. Faire tourner
|
|||
exportateur sous `postgres` serait donner les clés de la base pour lire des compteurs.
|
||||
|
||||
**Le mot de passe vient de la voûte.** Vide, l'exportateur n'est pas posé du tout : le rôle
|
||||
ne l'installe pas, ne crée pas le compte, et Prometheus ne dérive aucune cible. Dégrader,
|
||||
jamais deviner — et surtout jamais un mot de passe par défaut.
|
||||
ne l'installe pas et ne crée pas le compte — jamais un mot de passe par défaut. **Prometheus,
|
||||
lui, dérive quand même la cible** (`:9187`) de tout hôte de `serveur_postgresql` : sans le
|
||||
secret, la collecte d'`obs-01` passe au rouge. C'est voulu — une base sans métriques doit se
|
||||
voir, et renseigner le secret est la façon de l'éteindre. *(Cette page promettait l'inverse
|
||||
jusqu'au 2026-09-28.)*
|
||||
|
||||
**La chaîne de connexion ne passe pas par la ligne de commande.** Un `DATA_SOURCE_NAME` en
|
||||
argument serait lisible dans `ps` par tout le monde sur la machine ; dans un fichier à
|
||||
|
|
|
|||
|
|
@ -54,7 +54,6 @@ make instances
|
|||
OPS-Chezlepro-lab 13 1131-1136 LOCAL non
|
||||
* OPS-Chezlepro 17 1171-1176 oui oui
|
||||
OPS-Technolibre 23 1231-1236 oui non
|
||||
OPS-Patient0 29 1291-1296 oui ?
|
||||
* = instance active (symlink 'instance'). Basculer : make instance-utiliser NOM=<depot>
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ Raisons assumées :
|
|||
Ce document a longtemps écrit « ~13 VM ». Le moteur pilote aujourd'hui **quatre plans
|
||||
vivants** (mesuré le 2026-09-06) : Chezlepro 14 VM, Technolibre 15, le lab 15, plus les
|
||||
7 machines du site — **51 VM déclarées**, réparties sur des écosystèmes qui ne se parlent
|
||||
pas. *(Patient 0 en portait 5 ; ses machines n'existent plus.)* Ça reste très loin de l'échelle entreprise pour laquelle NetBox est fait,
|
||||
pas. Ça reste très loin de l'échelle entreprise pour laquelle NetBox est fait,
|
||||
et le seuil du §4 n'est pas franchi. Mais la courbe monte : c'est **le** chiffre à
|
||||
regarder quand on se demande si la décision tient encore.
|
||||
3. **Modèle sur-mesure** — NetBox exprimerait nos DSN / expositions à coups de
|
||||
|
|
|
|||
|
|
@ -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 | 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_nginx` | ingress | 80 | tcp | externe | clair | HTTP entrant — redirection permanente vers HTTPS. |
|
||||
| `serveur_nginx` | ingress | 443 | tcp | externe | tls-requis | HTTPS entrant — services exposés (terminaison TLS à l'edge). |
|
||||
| `serveur_nginx` | ingress | 80 | tcp | admin | clair | HTTP depuis le reseau d'administration — redirection permanente vers HTTPS. |
|
||||
| `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` | 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` | 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 | 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 | 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 | 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_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 | 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). |
|
||||
|
|
@ -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 | 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_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_frontal` | ingress | 80 | tcp | edge | clair | Contenu statique servi au navigateur via l'edge (TLS termine a l'edge). |
|
||||
| `serveur_web_frontal` | egress | 443 | tcp | flotte | tls | git clone/pull du depot du site (Forgejo souverain) au deploiement. |
|
||||
| `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` | 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
|
||||
|
||||
- **clair** : 50 flux
|
||||
- **clair** : 51 flux
|
||||
- **n-a** : 8 flux
|
||||
- **ssh** : 8 flux
|
||||
- **starttls** : 6 flux
|
||||
- **tls** : 11 flux
|
||||
- **tls** : 10 flux
|
||||
- **tls-cible** : 2 flux
|
||||
- **tls-requis** : 35 flux
|
||||
- **tls-requis** : 34 flux
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ contenu.
|
|||
- un temps 2 déclaré fait pendant que le plan ne révoque **aucune** clé.
|
||||
|
||||
Elle ne juge **pas** un écosystème sans registre : tous ne sont pas remis, et beaucoup ne
|
||||
le seront jamais — le lab, patient 0, l'écosystème de l'hébergeur lui-même.
|
||||
le seront jamais — le lab, l'écosystème de l'hébergeur lui-même.
|
||||
|
||||
## 7. Ce que cette procédure ne couvre pas
|
||||
|
||||
|
|
|
|||
|
|
@ -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 :
|
||||
|
||||
```
|
||||
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 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ù
|
||||
|
|
|
|||
|
|
@ -84,6 +84,20 @@ variables:
|
|||
DANS:
|
||||
invite: "Jours avant le second temps"
|
||||
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:
|
||||
invite: "Identifiant Proxmox de la VM"
|
||||
DIALECTE:
|
||||
|
|
@ -144,6 +158,12 @@ runbooks:
|
|||
pourquoi: >-
|
||||
Second passage. Celui-ci doit finir a zero echec ; s'il n'y arrive pas, c'est
|
||||
un vrai defaut, plus un maillon manquant.
|
||||
- cible: publier
|
||||
nature: ecriture
|
||||
variables: [DEPOT]
|
||||
pourquoi: >-
|
||||
Le geste quotidien : eregion ET la forge du site, puis la verification. Un
|
||||
`git push` seul laisse la forge du site en retard sans que rien le dise.
|
||||
- cible: genome-pousser
|
||||
nature: ecriture
|
||||
variables: [DEPOT]
|
||||
|
|
@ -193,6 +213,12 @@ runbooks:
|
|||
- cible: site-intrants
|
||||
nature: mesure
|
||||
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
|
||||
nature: mesure
|
||||
pourquoi: "Le playbook du site correspond-il encore aux couches declarees ?"
|
||||
|
|
@ -202,6 +228,21 @@ runbooks:
|
|||
- cible: routes-fabric-etat
|
||||
nature: mesure
|
||||
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
|
||||
nature: destructif
|
||||
variables: [SITE]
|
||||
|
|
@ -304,6 +345,12 @@ runbooks:
|
|||
pourquoi: >-
|
||||
Regenerer le registre des flux et les regles nftables depuis les `meta/flux.yml`
|
||||
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
|
||||
nature: mesure
|
||||
pourquoi: "Le pare-feu est-ouest intra-locataire, derive du registre des flux."
|
||||
|
|
@ -317,6 +364,19 @@ runbooks:
|
|||
nature: ecriture
|
||||
fixes: {CONFIRMER: "true"}
|
||||
pourquoi: "Poser IPSets, groupes et affectations — et retirer ce qui ne se declare plus."
|
||||
- cible: proxmox-fw-eprouver
|
||||
nature: mesure
|
||||
variables: [TENANT, HOTE]
|
||||
pourquoi: >-
|
||||
Avant d'activer le pare-feu d'UNE VM : les regles du devis tiennent-elles face aux
|
||||
flux que la VM recoit REELLEMENT ? Une VM reconstruite a perdu ses options.
|
||||
- cible: proxmox-fw-activer-vm
|
||||
nature: ecriture
|
||||
variables: [TENANT, HOTE]
|
||||
fixes: {CONFIRMER: "true"}
|
||||
pourquoi: >-
|
||||
Activer une VM a la fois, matrice avant et apres, Icinga lu. Chaque defaut du
|
||||
2026-09-28 ne s'est montre qu'a l'activation d'UNE VM.
|
||||
- cible: devis-opnsense
|
||||
nature: mesure
|
||||
pourquoi: "La frontiere nord/sud, derivee du meme registre de flux."
|
||||
|
|
@ -361,6 +421,14 @@ runbooks:
|
|||
pourquoi: >-
|
||||
Les VM manquantes, clonees depuis le gabarit dore. VMID, IP et VLAN sont DERIVES
|
||||
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
|
||||
nature: ecriture
|
||||
duree: "long"
|
||||
|
|
@ -439,6 +507,24 @@ runbooks:
|
|||
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.
|
||||
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
|
||||
nature: destructif
|
||||
portee: poste
|
||||
|
|
@ -453,11 +539,47 @@ runbooks:
|
|||
duree: "long"
|
||||
fixes: {CONFIRMER: "true"}
|
||||
pourquoi: >-
|
||||
Refaire tout depuis zero : les VM, puis le deploiement complet. Si le code ne
|
||||
suffit pas, c'est ici qu'on l'apprend.
|
||||
Refaire tout depuis zero : les VM, puis le deploiement complet — ou chaque role
|
||||
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
|
||||
nature: mesure
|
||||
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
|
||||
|
|
@ -704,8 +826,9 @@ runbooks:
|
|||
portee: poste
|
||||
doc: docs/sortir-les-cles-du-poste.md
|
||||
but: >-
|
||||
Ce qui n'existe QUE sur le poste meurt avec lui. Les voutes sortent chiffrees, sur un
|
||||
support qu'on relit — et on refait l'operation a chaque voute nouvelle.
|
||||
Ce qui n'existe QUE sur le poste meurt avec lui : les CLES, et les VOUTES qu'elles
|
||||
ouvrent (gitignorees, donc sur aucune forge). Les deux sortent, sur un support qu'on
|
||||
relit — et on refait l'operation a chaque voute nouvelle ou modifiee.
|
||||
etapes:
|
||||
- cible: cles-recenser
|
||||
nature: mesure
|
||||
|
|
@ -714,6 +837,15 @@ runbooks:
|
|||
nature: ecriture
|
||||
variables: [VERS]
|
||||
pourquoi: "Sortir les cles, chiffrees, et les RELIRE apres ecriture."
|
||||
- cible: voutes-recenser
|
||||
nature: mesure
|
||||
pourquoi: "Les voutes de ce poste, et leur empreinte. Une voute en clair est refusee avant tout."
|
||||
- cible: voutes-exporter
|
||||
nature: ecriture
|
||||
variables: [VERS]
|
||||
pourquoi: >-
|
||||
Sortir les voutes, deja chiffrees, dans leur propre archive, et la RELIRE. Sans
|
||||
elles, les cles restaurees n'ouvrent rien (2026-09-28).
|
||||
- cible: cles-compagnons
|
||||
nature: ecriture
|
||||
variables: [VERS]
|
||||
|
|
|
|||
|
|
@ -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.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 ;
|
||||
> 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.
|
||||
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
|
||||
|
||||
grep -qE '^(DROP|CREATE|ALTER) DATABASE|^\\connect' section.sql \
|
||||
|
|
|
|||
|
|
@ -6,10 +6,22 @@
|
|||
## Ce qui est en jeu
|
||||
|
||||
Le **code** de Set-OPS est répliqué **deux fois** : `eregion` et la forge du site. Les
|
||||
**voûtes chiffrées** y sont aussi.
|
||||
**voûtes chiffrées**, elles, n'y sont **pas** — elles sont gitignorées. Chacune vit sur
|
||||
ce poste et sur le runner de son écosystème, qui en reçoit une copie chiffrée par
|
||||
`serveur_ops_tenant`. *(Corrigé le 2026-09-28 : cette page les disait sur les forges.)*
|
||||
|
||||
> **Ce chiffre était trois, et il a baissé sans que rien ne le signale.** Le troisième
|
||||
> témoin était patient 0 ; ses machines n'existent plus. Un coffre répliqué deux fois reste
|
||||
**Elles sortent donc aussi, dans une archive à part** (`make voutes-exporter VERS=<support>`,
|
||||
`scripts/exporter_voutes.py`) : déjà chiffrées par `ansible-vault`, elles n'ont pas besoin
|
||||
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
|
||||
`vault.yml`) et relit l'archive empreinte par empreinte. Restauration :
|
||||
`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.
|
||||
|
||||
> **Ce chiffre était trois, et il a baissé sans que rien ne le signale.** Un coffre répliqué deux fois reste
|
||||
> solide — mais c'est la **redondance du génome** qui a diminué, pas le chiffrement, et
|
||||
> c'est exactement ce que la page *Filiation, signatures & témoins* appelle la vraie mesure
|
||||
> de résistance d'une lignée : combien de copies **vivantes**, sur combien de machines
|
||||
|
|
@ -68,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 :
|
||||
|
||||
```
|
||||
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
|
||||
LISEZ-MOI-RESTAURATION.txt le mode d'emploi, et les commandes manuelles
|
||||
```
|
||||
|
|
@ -80,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 :
|
||||
|
||||
```bash
|
||||
python3 restaurer_cles.py setops-cles-*.tar.gpg --lister # voir sans rien écrire
|
||||
python3 restaurer_cles.py setops-cles-*.tar.gpg # remettre en place
|
||||
A="$(ls -t setops-cles-*.tar* | head -1)" # la plus récente
|
||||
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**.
|
||||
|
|
|
|||
|
|
@ -22,9 +22,9 @@ identite_realm: "exemple"
|
|||
# `instance` du moteur, en dur. Tout role lisant le plan lisait donc celui de
|
||||
# l'instance POINTEE PAR LE LIEN, et non celle qu'on deploie.
|
||||
#
|
||||
# CE QUE CA A DONNE. En deployant patient 0 avec `SETOPS_INSTANCE`, le plancher
|
||||
# CE QUE CA A DONNE. En deployant un autre ecosysteme avec `SETOPS_INSTANCE`, le plancher
|
||||
# /etc/hosts de `ops-01` a recu les FQDN de CHEZLEPRO -- auth.chezlepro.internal,
|
||||
# forge.chezlepro.internal... -- pointes sur l'edge de patient 0. Un ecosysteme
|
||||
# forge.chezlepro.internal... -- pointes sur l'edge de l'autre. Un ecosysteme
|
||||
# annoncait les noms d'un autre. Neuf roles lisent cette variable ; le plancher est
|
||||
# simplement celui qui l'a rendu visible.
|
||||
#
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
# Sans ce fichier, `client_pki` n'emet le certificat de l'edge qu'avec ses propres noms
|
||||
# (`infra-edge-01.genese.internal`), et nginx retombe sur le certificat auto-signe de
|
||||
# Debian pour tout FQDN expose. Le service repond, la page s'affiche apres un
|
||||
# avertissement — et rien ne signale la panne. C'est ce qui s'est passe ici : la forge de
|
||||
# patient 0 etait publiee derriere un `ssl-cert-snakeoil.pem`, et `git clone` a ete le
|
||||
# avertissement — et rien ne signale la panne. C'est ce qui s'est passe ici : une forge
|
||||
# etait publiee derriere un `ssl-cert-snakeoil.pem`, et `git clone` a ete le
|
||||
# premier a refuser, a juste titre.
|
||||
#
|
||||
# Ce fichier appartient au MODELE parce que trois instances sur quatre le portaient
|
||||
|
|
|
|||
|
|
@ -32,7 +32,8 @@ vault_postgresql_keycloak: ""
|
|||
vault_bd_keycloak: ""
|
||||
vault_bd_forgejo: ""
|
||||
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: ""
|
||||
|
||||
# --- Forge (Forgejo) ---
|
||||
|
|
@ -75,7 +76,10 @@ vault_setops_console_oidc: ""
|
|||
# tourner l'exportateur en `postgres` serait donner les cles de la base pour lire des
|
||||
# compteurs.
|
||||
#
|
||||
# VIDE = PAS D'EXPORTATEUR DU TOUT. Le role ne le pose pas, ne cree pas le compte, et
|
||||
# Prometheus ne derive aucune cible. Degrader, jamais deviner — et surtout jamais un mot
|
||||
# de passe par defaut.
|
||||
# VIDE = PAS D'EXPORTATEUR DU TOUT. Le role ne le pose pas et ne cree pas le compte —
|
||||
# jamais un mot de passe par defaut. MAIS PROMETHEUS DERIVE QUAND MEME SA CIBLE (`:9187`)
|
||||
# pour tout hote de `serveur_postgresql`, secret ou pas : sans lui, la sonde `collecte`
|
||||
# d'obs-01 passe au rouge (« muettes : ...:9187 »). C'est voulu — une base sans metriques
|
||||
# se voit ; renseigner ce secret est la facon de l'eteindre. (Cette ligne promettait
|
||||
# l'inverse jusqu'au 2026-09-28 ; Chezlepro l'a vecu.)
|
||||
vault_pg_exportateur: ""
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ depots:
|
|||
composant: main
|
||||
# Sur CHAQUE machine, via `client_pki`. Le paquet le plus critique de la liste.
|
||||
paquets:
|
||||
step-cli: 0.30.6-1
|
||||
step-cli: 0.31.0-1
|
||||
|
||||
icinga:
|
||||
uri: https://packages.icinga.com/debian
|
||||
|
|
@ -37,12 +37,12 @@ depots:
|
|||
icinga-archive-keyring: 2.0.0-1+debian13
|
||||
icingacli: 2.14.0-2+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
|
||||
icinga-l10n: 1.4.0-1+debian13
|
||||
icinga-php-legacy: 1.1.0-1+debian13
|
||||
icinga-php-library: 1.0.0-2+debian13
|
||||
icinga-php-thirdparty: 1.0.0-2+debian13
|
||||
icinga-php-library: 1.0.1-1+debian13
|
||||
icinga-php-thirdparty: 1.0.1-1+debian13
|
||||
icingaweb2: 2.14.0-2+debian13
|
||||
icingaweb2-common: 2.14.0-2+debian13
|
||||
icingaweb2-module-monitoring: 2.12.6-1+debian13
|
||||
|
|
@ -53,7 +53,12 @@ depots:
|
|||
suite: stable
|
||||
composant: main
|
||||
# `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:
|
||||
alloy: 1.19.2-1
|
||||
grafana: 13.2.1
|
||||
loki: 3.7.7
|
||||
alloy: 1.20.1-1
|
||||
grafana: 13.2.3
|
||||
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
|
||||
roles:
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -18,6 +18,13 @@
|
|||
- name: Résoudre la connexion à l'annuaire
|
||||
ansible.builtin.include_role:
|
||||
name: resoudre_annuaire
|
||||
# LE COMPTE DU SERVICE QU'ON VERIFIE (2026-09-28). Depuis le 2026-09-13, chaque
|
||||
# consommateur de l'annuaire nomme SON compte ; ce devis n'en nommait aucun et
|
||||
# retombait sur `inconnu` — la cible echouait sur son assertion, avant toute
|
||||
# connexion. Il voit l'annuaire comme Postfix le voit : avec le compte de Postfix.
|
||||
vars:
|
||||
resoudre_annuaire_bind_cn: "postfix"
|
||||
resoudre_annuaire_secret: "vault_ldap_bind_postfix"
|
||||
|
||||
- name: Choisir un compte réel comme sonde
|
||||
community.general.ldap_search:
|
||||
|
|
|
|||
|
|
@ -63,6 +63,13 @@
|
|||
- name: Résoudre la connexion à l'annuaire
|
||||
ansible.builtin.include_role:
|
||||
name: resoudre_annuaire
|
||||
# LE COMPTE DU SERVICE QU'ON VERIFIE (2026-09-28). Depuis le 2026-09-13, chaque
|
||||
# consommateur de l'annuaire nomme SON compte ; ce devis n'en nommait aucun et
|
||||
# retombait sur `inconnu` — la cible echouait sur son assertion, avant toute
|
||||
# connexion. Il voit l'annuaire comme Keycloak le voit : avec le compte de Keycloak.
|
||||
vars:
|
||||
resoudre_annuaire_bind_cn: "keycloak"
|
||||
resoudre_annuaire_secret: "vault_ldap_bind_keycloak"
|
||||
|
||||
- name: Charger le registre des applications
|
||||
ansible.builtin.include_vars:
|
||||
|
|
|
|||
|
|
@ -160,6 +160,7 @@
|
|||
loop: "{{ genome_a_pousser }}"
|
||||
loop_control:
|
||||
label: "{{ item.dest }}"
|
||||
register: genome_emballe
|
||||
changed_when: true
|
||||
|
||||
- name: Porter les colis jusqu'au runner
|
||||
|
|
@ -255,6 +256,45 @@
|
|||
label: "{{ item.depot }}"
|
||||
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).
|
||||
#
|
||||
# C'est LUI qui pousse, donc il doit d'abord recevoir : ce playbook fait avancer le
|
||||
|
|
|
|||
|
|
@ -4,3 +4,15 @@
|
|||
name: auditd
|
||||
state: restarted
|
||||
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 }}"
|
||||
state: absent
|
||||
loop: "{{ auditd_fichiers_perimes }}"
|
||||
notify: Restart auditd
|
||||
notify:
|
||||
- Restart auditd
|
||||
- Recharger les regles auditd
|
||||
when: auditd_enabled | bool
|
||||
|
||||
# LA RETENTION EST UN CHOIX, DONC ELLE S'ECRIT (2026-09-11). Voir defaults/main.yml pour
|
||||
|
|
@ -48,7 +50,9 @@
|
|||
owner: root
|
||||
group: root
|
||||
mode: "0640"
|
||||
notify: Restart auditd
|
||||
notify:
|
||||
- Restart auditd
|
||||
- Recharger les regles auditd
|
||||
when: auditd_enabled | bool
|
||||
|
||||
# `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
|
||||
{% 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 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_staging` | `/var/backups/setops` | Préparation des dumps |
|
||||
| `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_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.
|
||||
- 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.
|
||||
- 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
|
||||
- `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.
|
||||
client_backup_paquets:
|
||||
- restic
|
||||
# `setops-restaurer` remet les repertoires par `rsync --delete` (absent du gabarit).
|
||||
- rsync
|
||||
|
||||
# Cible (nœud serveur_backup) + transport SSH.
|
||||
client_backup_cible: "backup-01.{{ domaine_interne }}"
|
||||
|
|
@ -18,6 +20,10 @@ client_backup_staging: "/var/backups/setops"
|
|||
|
||||
# Rétention (restic forget).
|
||||
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).
|
||||
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.
|
||||
nom: rspamd
|
||||
chemins: ["/var/lib/rspamd"]
|
||||
serveur_web_frontal:
|
||||
nom: web_frontal
|
||||
chemins: ["{{ serveur_web_frontal_racine_base | default('/srv/web') }}"]
|
||||
# PAS DE `serveur_web_frontal` (retire le 2026-09-30) : le frontal RELAIE, il ne sert
|
||||
# rien lui-meme — ses vhosts, son WAF et sa page 404 se redeploient depuis le depot.
|
||||
# `/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 --lier chunks --copier chunks/index
|
||||
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:
|
||||
nom: web_dorsal
|
||||
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_motdepasse: "{{ vault_icinga_api_depot | default('') }}"
|
||||
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"
|
||||
|
||||
# `ttl` du resultat passif : au-dela, Icinga perime le service de lui-meme — c'est ce qui
|
||||
|
|
@ -120,3 +161,35 @@ client_backup_verification_horaire: "*-*-* 01/4:00:00"
|
|||
client_backup_ttl_icinga: 21600
|
||||
client_backup_age_warn_h: 26
|
||||
client_backup_age_crit_h: 50
|
||||
|
||||
# LE CONTROLE DE RESTAURATION (2026-09-28) — voir `templates/verifier-restauration.sh.j2`.
|
||||
# Hebdomadaire : il relit une part du depot et restaure le dernier instantane pour de vrai.
|
||||
client_backup_restauration_horaire: "Sun *-*-* 04:00:00"
|
||||
client_backup_restauration_part: "10%"
|
||||
# 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`).
|
||||
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"
|
||||
|
|
|
|||
84
roles/client_backup/files/setops-copie-a-froid.sh
Normal file
84
roles/client_backup/files/setops-copie-a-froid.sh
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
#!/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] [--lier REL]... [--copier REL]...
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# `--lier REL` lie tout un sous-dossier ; `--copier REL` y copie vraiment un sous-dossier qui,
|
||||
# lui, bouge (2026-10-07). Pour Loki : `--lier chunks --copier chunks/index`. Ses morceaux sont
|
||||
# adresses par leur contenu (`debut:fin:somme`, decode sur site-mon-01) et ne bougent jamais ;
|
||||
# mais `chunks/index/` loge aussi `delete_requests.gz`, la base du compacteur, mesuree
|
||||
# reecrite trois semaines apres sa naissance. La lier, c'etait laisser la sauvegarde changer
|
||||
# avec elle. Copier 6 550 morceaux arretait Loki 13 s ; les lier prend 40 ms.
|
||||
set -euo pipefail
|
||||
|
||||
service="${1:?service}"
|
||||
source="${2:?source}"
|
||||
dest="${3:?destination}"
|
||||
shift 3
|
||||
blocs="" lier=() copier=()
|
||||
while (( $# )); do
|
||||
case "$1" in
|
||||
--blocs-immuables) blocs=1; shift ;;
|
||||
--lier) lier+=("$2"); shift 2 ;;
|
||||
--copier) copier+=("$2"); shift 2 ;;
|
||||
*) echo "copie a froid : option inconnue : $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -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
|
||||
|
||||
lie() { cp -al "$1" "$2" 2>/dev/null || { rm -rf "$2"; cp -a "$1" "$2"; }; }
|
||||
mkdir -p "${tmp}"
|
||||
for e in "${source}"/* "${source}"/.[!.]*; do
|
||||
[[ -e "${e}" ]] || continue
|
||||
n="$(basename "${e}")"
|
||||
if [[ -n "${blocs}" && -d "${e}" && ${#n} -eq 26 && "${n}" == 01* ]] \
|
||||
|| [[ " ${lier[*]:-} " == *" ${n} "* ]]; then
|
||||
lie "${e}" "${tmp}/${n}"
|
||||
else
|
||||
cp -a "${e}" "${tmp}/${n}"
|
||||
fi
|
||||
done
|
||||
# Ce qui bouge, a l'interieur d'un dossier lie : remplace par une vraie copie.
|
||||
for c in "${copier[@]:+${copier[@]}}"; do
|
||||
if [[ -e "${source}/${c}" ]]; then
|
||||
rm -rf "${tmp:?}/${c}"
|
||||
mkdir -p "$(dirname "${tmp}/${c}")"
|
||||
cp -a "${source}/${c}" "${tmp}/${c}"
|
||||
fi
|
||||
done
|
||||
|
||||
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
|
||||
ansible.builtin.systemd:
|
||||
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(', ')),
|
||||
'aucun etat non regenerable — aucune sauvegarde installee') }}
|
||||
|
||||
- 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
|
||||
# L'ACCES AU DEPOT vit a part : les roles proprietaires l'incluent aussi, pour RESTAURER
|
||||
# avant meme que ce role-ci ait tourne (l'AC se restaure dans la couche `pki_racine`).
|
||||
- name: Accès au dépôt (restic, clé, mot de passe, SSH) et outil de restauration
|
||||
ansible.builtin.include_tasks: acces.yml
|
||||
|
||||
- name: Assurer le répertoire de préparation des dumps
|
||||
ansible.builtin.file:
|
||||
|
|
@ -106,6 +56,17 @@
|
|||
group: root
|
||||
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
|
||||
when: client_backup_jobs | length > 0
|
||||
ansible.builtin.template:
|
||||
|
|
@ -117,6 +78,7 @@
|
|||
loop:
|
||||
- { s: setops-sauvegarde.service.j2, d: setops-sauvegarde.service }
|
||||
- { 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
|
||||
when:
|
||||
|
|
@ -140,6 +102,24 @@
|
|||
state: stopped
|
||||
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
|
||||
when: client_backup_jobs | length == 0
|
||||
ansible.builtin.file:
|
||||
|
|
@ -147,8 +127,16 @@
|
|||
state: absent
|
||||
loop:
|
||||
- /usr/local/sbin/setops-sauvegarder.sh
|
||||
- /usr/local/sbin/setops-copie-a-froid
|
||||
- /etc/systemd/system/setops-sauvegarde.service
|
||||
- /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
|
||||
|
||||
# --- 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
|
||||
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
|
||||
ansible.builtin.apt:
|
||||
name: curl
|
||||
|
|
@ -76,3 +115,32 @@
|
|||
enabled: true
|
||||
state: started
|
||||
daemon_reload: true
|
||||
|
||||
# LE CONTROLE DE RESTAURATION (2026-09-28). Meme destinataire, meme compte, un service a
|
||||
# part : « l'instantane existe » et « l'instantane se rouvre » ne sont pas la meme question.
|
||||
- name: Déployer le contrôle de restauration
|
||||
ansible.builtin.template:
|
||||
src: verifier-restauration.sh.j2
|
||||
dest: /usr/local/sbin/setops-verifier-restauration.sh
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0700"
|
||||
|
||||
- name: Déployer l'unité et le timer du contrôle de restauration
|
||||
ansible.builtin.template:
|
||||
src: "{{ item }}.j2"
|
||||
dest: "/etc/systemd/system/{{ item }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
loop:
|
||||
- setops-verification-restauration.service
|
||||
- setops-verification-restauration.timer
|
||||
|
||||
- name: Activer le timer du contrôle de restauration
|
||||
ansible.builtin.systemd_service:
|
||||
name: setops-verification-restauration.timer
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: true
|
||||
when: not ansible_check_mode
|
||||
|
|
|
|||
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
|
||||
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 }} "$@"; }
|
||||
|
||||
# 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.
|
||||
{% 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
|
||||
exit 0
|
||||
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.
|
||||
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
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
# GENERE par Set-OPS (role client_backup). Ne pas editer a la main.
|
||||
[Unit]
|
||||
Description=La sauvegarde se restaure-t-elle ? (controle hebdomadaire, rapport a Icinga)
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
Nice=10
|
||||
IOSchedulingClass=idle
|
||||
ExecStart=/usr/local/sbin/setops-verifier-restauration.sh
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
# GENERE par Set-OPS (role client_backup). Ne pas editer a la main.
|
||||
[Unit]
|
||||
Description=Controle hebdomadaire de restauration
|
||||
|
||||
[Timer]
|
||||
OnCalendar={{ client_backup_restauration_horaire }}
|
||||
RandomizedDelaySec=3600
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
|
|
@ -14,6 +14,12 @@
|
|||
set -uo pipefail
|
||||
|
||||
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 }}"
|
||||
API="https://{{ client_backup_icinga_hote }}.{{ domaine_interne }}:5665"
|
||||
TTL={{ client_backup_ttl_icinga }}
|
||||
|
|
@ -22,6 +28,16 @@ AGE_CRIT={{ client_backup_age_crit_h }}
|
|||
MOI="{{ inventory_hostname }}"
|
||||
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
|
||||
local charge reponse
|
||||
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)
|
||||
|
||||
if (( fichiers == 0 )); then
|
||||
# AVERTISSEMENT et non CRITIQUE : la machine ne peut pas distinguer « les donnees ont
|
||||
# disparu » de « il n'y en a pas encore » (un /srv/web sans webapp). C'est a un humain
|
||||
# de trancher — mais il doit le VOIR.
|
||||
rapporter 1 "N'EMPORTE RIEN : instantane sans aucun fichier (${taille} octets). Legitime si ce noeud n'a pas encore de donnees — a confirmer."
|
||||
# INSTANTANE VIDE : on regarde ce que les chemins sauvegardes contiennent ICI, sur le
|
||||
# noeud. Trois cas, trois messages (2026-10-04) :
|
||||
# - rien sur le noeud : rien a sauvegarder, l'instantane vide est juste. Avertissement
|
||||
# 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
|
||||
rapporter 2 "PERIME : dernier instantane il y a ${age_h} h (seuil ${AGE_CRIT} h), ${fichiers} fichier(s)."
|
||||
elif (( age_h >= AGE_WARN )); then
|
||||
|
|
|
|||
107
roles/client_backup/templates/verifier-restauration.sh.j2
Normal file
107
roles/client_backup/templates/verifier-restauration.sh.j2
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
#!/bin/bash
|
||||
# GENERE par Set-OPS (role client_backup). Ne pas editer a la main.
|
||||
#
|
||||
# LA SAUVEGARDE SE RESTAURE-T-ELLE ? (2026-09-28)
|
||||
#
|
||||
# `setops-verification-depot` dit qu'un instantane EXISTE, qu'il est RECENT et qu'il n'est
|
||||
# pas VIDE. Il ne dit pas qu'on saurait le rouvrir. Le 2026-09-28, c'est a la main qu'on a
|
||||
# restaure les dumps PostgreSQL et LDAP pour le savoir — et le meme jour on decouvrait que
|
||||
# deux locataires sur deux ne deposaient rien hors de leur flotte depuis des semaines.
|
||||
# Une sauvegarde jamais restauree est une hypothese.
|
||||
#
|
||||
# CE QUE CE CONTROLE FAIT, chaque semaine, sur CE noeud (le seul a detenir la cle) :
|
||||
# 1. `restic check --read-data-subset` : l'integrite du depot, en RELISANT une part des
|
||||
# donnees — pas seulement l'index ;
|
||||
# 2. une vraie restauration du dernier instantane dans un repertoire temporaire, avec
|
||||
# `--verify` : le contenu restaure est recompare a l'instantane ;
|
||||
# 3. le NOMBRE de fichiers restaures face a celui de l'instantane.
|
||||
# Le repertoire est detruit ensuite, quoi qu'il arrive.
|
||||
set -uo pipefail
|
||||
|
||||
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 }}"
|
||||
API="https://{{ client_backup_icinga_hote }}.{{ domaine_interne }}:5665"
|
||||
TTL={{ client_backup_ttl_restauration }}
|
||||
PART="{{ client_backup_restauration_part }}"
|
||||
MOI="{{ inventory_hostname }}"
|
||||
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
|
||||
local charge reponse
|
||||
charge=$(python3 -c 'import json,sys; print(json.dumps({
|
||||
"type": "Service", "service": sys.argv[1], "exit_status": int(sys.argv[2]),
|
||||
"plugin_output": sys.argv[3], "ttl": int(sys.argv[4])}))' \
|
||||
"${MOI}!restauration" "$1" "$2" "${TTL}")
|
||||
reponse=$(curl -sS --cacert "{{ client_backup_ca_verification }}" --max-time 20 \
|
||||
-u "{{ client_backup_icinga_utilisateur }}:${MOTDEPASSE}" \
|
||||
-H 'Accept: application/json' -H 'Content-Type: application/json' \
|
||||
-X POST "${API}/v1/actions/process-check-result" -d "${charge}" 2>&1)
|
||||
if ! printf '%s' "${reponse}" | grep -q '"code": *200'; then
|
||||
echo "ECHEC du rapport Icinga : ${reponse}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
CIBLE=$(mktemp -d /var/tmp/setops-restauration.XXXXXX)
|
||||
trap 'rm -rf "${CIBLE}"' EXIT
|
||||
|
||||
# 1. Integrite, en relisant les donnees.
|
||||
if ! sortie=$(restic check --read-data-subset="${PART}" 2>&1); then
|
||||
rapporter 2 "DEPOT CORROMPU OU ILLISIBLE : restic check (${PART} relus) a echoue — $(printf '%s' "${sortie}" | tail -1 | cut -c1-120)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 2. Ce que l'instantane contient.
|
||||
attendus=$(restic ls latest --json 2>/dev/null | python3 -c '
|
||||
import json, sys
|
||||
n = 0
|
||||
for l in sys.stdin:
|
||||
try: d = json.loads(l)
|
||||
except ValueError: continue
|
||||
if d.get("struct_type") == "node" and d.get("type") == "file": n += 1
|
||||
print(n)' 2>/dev/null || echo -1)
|
||||
if (( attendus < 0 )); then
|
||||
rapporter 2 "AUCUN INSTANTANE lisible dans ${RESTIC_REPOSITORY} — rien a restaurer."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 3. Restaurer pour de vrai, et recomparer.
|
||||
if ! sortie=$(restic restore latest --target "${CIBLE}" --verify 2>&1); then
|
||||
rapporter 2 "RESTAURATION EN ECHEC : $(printf '%s' "${sortie}" | tail -1 | cut -c1-140)"
|
||||
exit 0
|
||||
fi
|
||||
obtenus=$(find "${CIBLE}" -type f | wc -l)
|
||||
octets=$(du -sb "${CIBLE}" | cut -f1)
|
||||
|
||||
if (( obtenus != attendus )); then
|
||||
rapporter 2 "RESTAURATION INCOMPLETE : ${obtenus} fichier(s) restaure(s) sur ${attendus} dans l'instantane."
|
||||
elif (( attendus == 0 )); then
|
||||
# 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
|
||||
rapporter 0 "Restauration verifiee : ${obtenus} fichier(s), ${octets} octets, contenu recompare ; integrite du depot relue sur ${PART}."
|
||||
fi
|
||||
|
|
@ -19,5 +19,7 @@ client_backup_groupes_etat:
|
|||
- serveur_forgejo
|
||||
- serveur_nextcloud
|
||||
- serveur_rspamd
|
||||
- serveur_web_frontal
|
||||
- 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.
|
||||
client_journal_audit_actif: "{{ 'serveur_durci' in group_names }}"
|
||||
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.
|
||||
sondes:
|
||||
- nom: journaux
|
||||
metier: socle
|
||||
ttl: 5400
|
||||
raison: >-
|
||||
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.
|
||||
- name: Assurer le repertoire d etat des sondes
|
||||
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
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0750"
|
||||
mode: "0755"
|
||||
|
||||
- name: Deposer la sonde d expedition des journaux
|
||||
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" {
|
||||
forward_to = [loki.write.loki.receiver]
|
||||
forward_to = [{{ 'loki.process.sondes.receiver' if sondeurs else 'loki.write.loki.receiver' }}]
|
||||
max_age = "12h"
|
||||
labels = {
|
||||
job = "systemd-journal",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
# donc DECLARES sans objet, dans une liste qu'une machine de metal peut vider.
|
||||
sondes:
|
||||
- nom: metriques
|
||||
metier: socle
|
||||
# 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.
|
||||
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
|
||||
# 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.
|
||||
#
|
||||
# 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_sans: >-
|
||||
{{ ([client_pki_nom_cert,
|
||||
|
|
@ -52,6 +62,14 @@ client_pki_sans: >-
|
|||
| selectattr('edge', 'defined')
|
||||
| selectattr('fqdn', 'defined')
|
||||
| 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))
|
||||
| select | unique | list }}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
# contraire. La sonde compare donc les deux quand un service consomme le certificat.
|
||||
sondes:
|
||||
- nom: certificat
|
||||
metier: socle
|
||||
# 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,
|
||||
# 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
|
||||
# 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.
|
||||
#
|
||||
# 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é
|
||||
ansible.builtin.command:
|
||||
cmd: "step certificate fingerprint {{ serveur_step_ca_steppath | default('/etc/step-ca') }}/certs/root_ca.crt"
|
||||
delegate_to: "{{ groups['serveur_step_ca'][0] }}"
|
||||
run_once: true
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
register: client_pki_fingerprint_ac
|
||||
|
|
|
|||
|
|
@ -52,6 +52,11 @@ client_sante_unites_tolerees: []
|
|||
# Six passages du porteur.
|
||||
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
|
||||
# n'a pas de verdict, et on le dit (INCONNU) plutot que de figer tout le rapport.
|
||||
client_sante_delai_sonde: 20
|
||||
|
|
@ -63,3 +68,21 @@ client_sante_delai_sonde: 20
|
|||
client_sante_paquets:
|
||||
- curl
|
||||
- monitoring-plugins-basic
|
||||
|
||||
# LA SONDE « disque » (2026-09-28) — pourcentage UTILISE, pour l'espace comme pour les
|
||||
# inodes. Mesure le jour de sa pose : au plus 22 % d'espace et 7 % d'inodes sur les 35
|
||||
# machines ; ces seuils laissent donc voir une croissance longtemps avant la panne.
|
||||
client_sante_disque_avert: 80
|
||||
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
|
||||
ansible.builtin.systemd:
|
||||
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
|
||||
|
|
|
|||
28
roles/client_sante/meta/supervision.yml
Normal file
28
roles/client_sante/meta/supervision.yml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
# Supervision derivee du role. Voir docs/supervision-conception.md.
|
||||
#
|
||||
# `client_sante` EXECUTE les sondes de tous les roles ; il en DECLARE une a lui (2026-09-28).
|
||||
#
|
||||
# « disque » vaut pour tout hote qui rapporte — exactement le groupe de ce role. Elle est
|
||||
# declaree ICI, et non par `serveur_debian` comme `correctifs` : la poser par
|
||||
# `common_packages` aurait impose un `upgrade: full` de toute la flotte pour deposer un
|
||||
# script, et P64 veut, a juste titre, que la declaration suive le role qui depose.
|
||||
sondes:
|
||||
- nom: disque
|
||||
metier: socle
|
||||
ttl: 5400
|
||||
raison: >-
|
||||
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
|
||||
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
|
||||
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
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
mode: "0700"
|
||||
|
||||
- name: Déposer le mot de passe d'API Icinga
|
||||
ansible.builtin.copy:
|
||||
|
|
@ -142,9 +145,52 @@
|
|||
{{ group_names | map('extract', client_sante_registre) | select('defined')
|
||||
| select('sequence') | flatten | map(attribute='nom') | list }}
|
||||
|
||||
# LA SONDE « disque » EST POSEE ICI, PAS PAR `common_packages` (2026-09-28). Elle est
|
||||
# DECLAREE par ce role (`meta/supervision.yml`) — tout hote qui rapporte la porte. Mais
|
||||
# `common_packages` fait un `upgrade: full` a chaque passage : la poser par lui aurait
|
||||
# impose une mise a jour de toute la flotte pour deposer un script. Ce role-ci execute
|
||||
# deja les sondes, et se redeploie sans toucher au systeme.
|
||||
- name: Assurer le repertoire des sondes
|
||||
ansible.builtin.file:
|
||||
path: /usr/local/lib/setops/sondes
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
|
||||
- name: Deposer la sonde de l'espace disque
|
||||
ansible.builtin.template:
|
||||
src: sonde-disque.sh.j2
|
||||
dest: /usr/local/lib/setops/sondes/disque.sh
|
||||
owner: root
|
||||
group: root
|
||||
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
|
||||
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"
|
||||
register: client_sante_posees
|
||||
|
||||
|
|
@ -190,11 +236,33 @@
|
|||
state: started
|
||||
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
|
||||
# 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
|
||||
# tableau 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
|
||||
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
|
||||
# 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.
|
||||
sondes() {
|
||||
sondes() { # $1=repertoire $2=ttl des resultats
|
||||
local f nom sortie code
|
||||
for f in /usr/local/lib/setops/sondes/*.sh; do
|
||||
for f in "$1"/*.sh; do
|
||||
[[ -x "$f" ]] || continue
|
||||
nom=$(basename "$f" .sh)
|
||||
# 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.
|
||||
sortie=$(timeout {{ client_sante_delai_sonde }} "$f" 2>&1); code=$?
|
||||
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
|
||||
fi
|
||||
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
|
||||
rapporter "$nom" "$code" "${sortie:-(aucun message)}" "{{ client_sante_ttl_sondes }}"
|
||||
rapporter "$nom" "$code" "${sortie:-(aucun message)}" "$2"
|
||||
fi
|
||||
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[@]}
|
||||
if [[ $n -eq 0 ]]; then
|
||||
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 ))
|
||||
fi
|
||||
|
||||
|
|
@ -147,5 +157,5 @@ fi
|
|||
# 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.
|
||||
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 ))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
52
roles/client_sante/templates/sonde-disque.sh.j2
Normal file
52
roles/client_sante/templates/sonde-disque.sh.j2
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
#!/bin/bash
|
||||
# GENERE par Set-OPS (role client_sante). Ne pas editer a la main.
|
||||
#
|
||||
# SONDE « disque » — reste-t-il de la place, et des inodes, sur chaque systeme de fichiers ?
|
||||
#
|
||||
# Contrat : docs/supervision-conception.md (API des greffons Nagios) — une ligne, 0/1/2.
|
||||
# Seuils : `client_sante_disque_avert` / `client_sante_disque_crit` (pourcentage utilise),
|
||||
# les memes pour l'espace et pour les inodes.
|
||||
#
|
||||
# POURQUOI ELLE MANQUAIT (2026-09-28). Seuls les volumes du cache apt et des depots de
|
||||
# sauvegarde etaient surveilles. Rien ne regardait le disque de la base, des boites, de
|
||||
# Nextcloud, ni de Loki qui grossit chaque jour. Prometheus collecte bien l'occupation,
|
||||
# mais aucune regle n'en fait une alerte : un disque plein arretait la base ou le courriel
|
||||
# sans rien dire avant. Les INODES aussi : une file de courriel ou un cache de petits
|
||||
# fichiers les epuise avec la moitie de l'espace libre, et le message d'erreur parle alors
|
||||
# d'un disque « plein » qu'un `df` dit a moitie vide.
|
||||
set -uo pipefail
|
||||
|
||||
AVERT={{ client_sante_disque_avert }}
|
||||
CRIT={{ client_sante_disque_crit }}
|
||||
# Pas les pseudo-systemes : ils se remplissent et se vident par nature.
|
||||
EXCLUS=(-x tmpfs -x devtmpfs -x squashfs -x overlay -x efivarfs)
|
||||
|
||||
pire=0; pire_quoi=""; perf=""
|
||||
lire() { # $1 = espace|inodes, puis la sortie de df -P[i]
|
||||
local genre=$1 ligne pct mp
|
||||
while read -r ligne; do
|
||||
pct=$(awk '{print $5}' <<<"${ligne}" | tr -d '%')
|
||||
mp=$(awk '{print $6}' <<<"${ligne}")
|
||||
[[ "${pct}" =~ ^[0-9]+$ ]] || continue # `-` : pas d'inodes sur ce type
|
||||
local etiq=${mp#/}; etiq=${etiq//\//_}; etiq=${etiq:-racine}
|
||||
perf+="${genre}_${etiq}=${pct}%;${AVERT};${CRIT};0;100 "
|
||||
if (( pct > pire )); then pire=${pct}; pire_quoi="${mp} (${genre}) a ${pct} %"; fi
|
||||
done
|
||||
}
|
||||
lire espace < <(df -P "${EXCLUS[@]}" 2>/dev/null | tail -n +2)
|
||||
lire inodes < <(df -Pi "${EXCLUS[@]}" 2>/dev/null | tail -n +2)
|
||||
|
||||
if [[ -z "${pire_quoi}" ]]; then
|
||||
echo "Aucun systeme de fichiers lisible par df."
|
||||
exit 3
|
||||
fi
|
||||
if (( pire >= CRIT )); then
|
||||
echo "DISQUE CRITIQUE : ${pire_quoi} (seuil ${CRIT} %). | ${perf}"
|
||||
exit 2
|
||||
fi
|
||||
if (( pire >= AVERT )); then
|
||||
echo "Disque en tension : ${pire_quoi} (seuil ${AVERT} %). | ${perf}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Place suffisante : le plus rempli est ${pire_quoi}. | ${perf}"
|
||||
exit 0
|
||||
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
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
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
|
||||
ansible.builtin.apt:
|
||||
upgrade: full
|
||||
|
|
@ -124,11 +159,16 @@
|
|||
|
||||
- name: Assurer le repertoire d etat des sondes
|
||||
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
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0750"
|
||||
mode: "0755"
|
||||
|
||||
- name: Deposer la sonde des correctifs de securite
|
||||
ansible.builtin.template:
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ hosts_statiques_expositions: []
|
|||
#
|
||||
# LE PIÈGE, MESURÉ LE 2026-08-23. `forge.alliance-boreale.ca` résout vers 192.168.14.66
|
||||
# depuis le poste d'administration, et vers 69.70.26.51 — l'adresse PUBLIQUE — depuis
|
||||
# l'overlay de patient 0, qui ne sait pas l'atteindre de l'intérieur (pas de retour en
|
||||
# l'overlay d'un écosystème, qui ne sait pas l'atteindre de l'intérieur (pas de retour en
|
||||
# épingle). Le clone initial du miroir avait pourtant réussi : la résolution n'est pas
|
||||
# stable, elle dépend du résolveur interrogé. Un miroir qui se synchronise toutes les
|
||||
# huit heures serait tombé dessus tôt ou tard, et le silence aurait duré.
|
||||
|
|
|
|||
|
|
@ -19,10 +19,12 @@ ff02::2 ip6-allrouters
|
|||
{% if hosts_statiques_publier_expositions | default(false) and hosts_statiques_expositions %}
|
||||
|
||||
# --- 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 %}
|
||||
{% set edge_hosts = groups.get(expo.edge, []) %}
|
||||
{% if edge_hosts and hostvars[edge_hosts[0]].ansible_host is defined %}
|
||||
{{ "%-15s" | format(hostvars[edge_hosts[0]].ansible_host) }} {{ expo.fqdn }}
|
||||
{% set cibles = [expo.hote] if expo.resout_vers | default('edge') == 'service' else groups.get(expo.edge, []) %}
|
||||
{% if cibles and cibles[0] in hostvars and hostvars[cibles[0]].ansible_host is defined %}
|
||||
{{ "%-15s" | format(hostvars[cibles[0]].ansible_host) }} {{ expo.fqdn }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% 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
|
||||
# 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"
|
||||
|
||||
# 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
|
||||
when: not (nftables_baseline_enabled | bool)
|
||||
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.
|
||||
paquets_tiers_cache: "{{ lookup('env', 'HOME') }}/.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 :
|
||||
# l'appelant installera depuis le dépôt distant, comme avant. On ne casse pas un
|
||||
# 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
|
||||
ansible.builtin.slurp:
|
||||
src: "{{ paquets_tiers_cache }}/MANIFESTE"
|
||||
|
|
@ -65,6 +69,13 @@
|
|||
loop_control:
|
||||
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
|
||||
# — 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
|
||||
|
|
@ -74,12 +85,16 @@
|
|||
- name: Installer les paquets tiers depuis le cache
|
||||
ansible.builtin.command:
|
||||
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()
|
||||
| map('regex_replace', '^', paquets_tiers_dest ~ '/') | list) }}
|
||||
environment:
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
register: paquets_tiers_pose
|
||||
changed_when: "'0 nouvellement installés' not in paquets_tiers_pose.stdout
|
||||
and '0 newly installed' not in paquets_tiers_pose.stdout"
|
||||
# UNE MISE À JOUR EST UN CHANGEMENT (2026-10-03). La garde ne cherchait que « 0 nouvellement
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -172,5 +172,7 @@ serveur_artefacts_directs:
|
|||
url: "{{ serveur_keycloak_signature_url }}"
|
||||
- nom: "nextcloud-{{ serveur_nextcloud_version }}.tar.bz2"
|
||||
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"
|
||||
url: "{{ serveur_oauth2_proxy_url }}"
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@
|
|||
# Supervision derivee du role. Voir docs/supervision-conception.md.
|
||||
sondes:
|
||||
- nom: cache-apt
|
||||
metier: paquets
|
||||
ttl: 5400
|
||||
raison: Le cache d'artefacts repond-il ? S'il se tait, TOUT apt de l'ecosysteme echoue — on agit dans
|
||||
la minute.
|
||||
- nom: cache-apt-volume
|
||||
metier: paquets
|
||||
ttl: 5400
|
||||
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.'
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@
|
|||
# n'atteint jamais la couche qui poserait le bon amont. Le correctif se retrouve dans la
|
||||
# couche que la panne empeche d'atteindre, et il faut une main pour en sortir.
|
||||
#
|
||||
# C'est exactement ce qui est arrive : l'amont pointait sur le cache de patient 0, eteint
|
||||
# C'est exactement ce qui est arrive : l'amont pointait sur le cache d'un autre ecosysteme, eteint
|
||||
# la veille pour liberer de la RAM. `apt` rendait `503 Connection timeout` EN CITANT
|
||||
# L'ADRESSE DU CACHE LOCAL, jamais celle de l'amont manquant — la panne accusait le
|
||||
# maillon visible.
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
# peremption. Le silence alerte autant que l'echec.
|
||||
sondes:
|
||||
- nom: depot
|
||||
metier: donnees
|
||||
ttl: 5400
|
||||
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
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ vivaient sur `backup-01`, une VM de sa propre flotte.** Raser l'écosystème aur
|
|||
les données et leur seul filet dans le même geste.
|
||||
|
||||
La doctrine affirmait pourtant que `client_backup_cible` *« pointe déjà hors de
|
||||
l'écosystème »*. C'était vrai pour patient 0, qui sauvegarde chez `eregion` — faux pour
|
||||
l'écosystème »*. C'était faux pour
|
||||
Chezlepro. *Un document qui décrit une propriété que le réel n'a pas est exactement ce que
|
||||
ce dépôt traque.*
|
||||
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@
|
|||
# CE QUE SON ABSENCE COÛTAIT (mesuré le 2026-09-01). Chezlepro sauvegardait sur
|
||||
# `backup-01`, une VM de sa PROPRE flotte. Raser l'écosystème aurait détruit les données
|
||||
# et leur seul filet dans le même geste — et la doctrine affirmait pourtant que
|
||||
# `client_backup_cible` « pointe déjà hors de l'écosystème ». C'était vrai pour patient 0,
|
||||
# faux pour Chezlepro.
|
||||
# `client_backup_cible` « pointe déjà hors de l'écosystème ». C'était faux pour
|
||||
# Chezlepro.
|
||||
#
|
||||
# `voisins_site` désigne les autres tenants que CE SITE héberge. Ni `flotte`, ni `externe` :
|
||||
# un dépôt de sauvegarde ouvert à l'Internet serait la pire porte du site.
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
# peremption. Le silence alerte autant que l'echec.
|
||||
sondes:
|
||||
- nom: depot-locataires
|
||||
metier: sauvegardes_locataires
|
||||
ttl: 5400
|
||||
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
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
# AVANT le marqueur, dans le MÊME play, donc ses variables sont en portée. Et l'ordre
|
||||
# cesse de dépendre de la façon dont on lance le déploiement.
|
||||
#
|
||||
# Chez patient 0 les deux vivaient sur le même hôte et étaient appliqués ensemble : la
|
||||
# Dans le premier écosystème les deux vivaient sur le même hôte et étaient appliqués ensemble : la
|
||||
# dépendance existait déjà, elle n'était simplement écrite nulle part.
|
||||
dependencies:
|
||||
- role: serveur_artefacts
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
# peremption. Le silence alerte autant que l'echec.
|
||||
sondes:
|
||||
- nom: cache-site-racine
|
||||
metier: paquets
|
||||
ttl: 5400
|
||||
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
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
# CE RÔLE MARQUE UN CACHE ; IL N'EN INSTALLE PAS.
|
||||
#
|
||||
# Il présuppose `serveur_artefacts` sur le même hôte — c'est lui qui pose apt-cacher-ng —
|
||||
# et lit ses variables. Chez patient 0 les deux vivaient sur `forge-01`, donc la
|
||||
# et lit ses variables. Dans le premier écosystème les deux vivaient sur `forge-01`, donc la
|
||||
# dépendance ne se voyait pas. La première machine à ne porter que le marqueur l'a
|
||||
# révélée, et le message était : « variable non définie : `serveur_artefacts_port` ».
|
||||
#
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
# peremption. Le silence alerte autant que l'echec.
|
||||
sondes:
|
||||
- nom: edition
|
||||
metier: edition
|
||||
ttl: 5400
|
||||
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
|
||||
|
|
|
|||
|
|
@ -27,12 +27,14 @@
|
|||
# correctif publie en exposition acceptee.
|
||||
sondes:
|
||||
- nom: correctifs
|
||||
metier: socle
|
||||
ttl: 5400
|
||||
raison: >-
|
||||
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 :
|
||||
sans cette mesure, rien ne dit quand le geste est du.
|
||||
- nom: horloge
|
||||
metier: socle
|
||||
ttl: 5400
|
||||
raison: >-
|
||||
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.
|
||||
sondes:
|
||||
- nom: zones-publiques
|
||||
metier: noms_publics
|
||||
ttl: 5400
|
||||
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
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
# Supervision derivee du role. Voir docs/supervision-conception.md.
|
||||
sondes:
|
||||
- nom: boites
|
||||
metier: courriel
|
||||
ttl: 5400
|
||||
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
|
||||
|
|
|
|||
|
|
@ -51,6 +51,41 @@
|
|||
group: "{{ serveur_dovecot_vmail_utilisateur }}"
|
||||
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) ---
|
||||
- name: TLS — créer le répertoire des certificats
|
||||
ansible.builtin.file:
|
||||
|
|
|
|||
|
|
@ -18,8 +18,25 @@
|
|||
# laisse la meme trace qu'un audit en bonne sante pour qui ne regarde que `systemctl`.
|
||||
sondes:
|
||||
- nom: audit
|
||||
metier: socle
|
||||
ttl: 5400
|
||||
# Posee par `auditd` seulement si `auditd_enabled` : Icinga ne l'attend qu'a ces
|
||||
# conditions (voir `setops-sondes.conf.j2`, 2026-09-28).
|
||||
seulement_si: auditd_enabled
|
||||
defini_par: auditd
|
||||
raison: >-
|
||||
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
|
||||
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.
|
||||
sondes:
|
||||
- nom: genome-servi
|
||||
metier: forge
|
||||
ttl: 5400
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
# 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: >-
|
||||
https://codeberg.org/forgejo/forgejo/releases/download/v{{ serveur_forgejo_version }}/forgejo-{{ serveur_forgejo_version }}-linux-amd64
|
||||
|
||||
|
|
@ -105,8 +105,8 @@ serveur_forgejo_oidc_discovery: >-
|
|||
#
|
||||
# POURQUOI CE CHOIX EXISTE (2026-08-22). Une offre `forge` pour un petit organisme
|
||||
# exigeait une VM PostgreSQL entiere — un serveur, une zone, un secret, une sauvegarde —
|
||||
# pour une base que trois personnes sollicitent. Le premier ecosysteme a en avoir profite
|
||||
# est patient 0, qui porte le genome : moins de surface sur la machine dont tout descend.
|
||||
# pour une base que trois personnes sollicitent. Moins de surface, aussi, sur une forge
|
||||
# qui porte le genome : c'est la machine dont tout descend.
|
||||
#
|
||||
# CE QUE `sqlite` CHANGE AILLEURS : rien a la sauvegarde — le job `serveur_forgejo` de
|
||||
# `client_backup` emporte deja `serveur_forgejo_data`, ou le fichier se trouve. Et rien
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue