Compare commits
144 commits
essai_inte
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 548d35b1b5 | |||
| 6df04888ab | |||
| 3fc16c845d | |||
| 82a2c4a4c2 | |||
| d7717a4f98 | |||
| 9af39b1b60 | |||
| aa5e4e0897 | |||
| 0d5ec924cc | |||
| 1d582045f3 | |||
| 9592309f36 | |||
| 493a5dcbeb | |||
| 412133bd76 | |||
| ee0e33d452 | |||
| dd4ca09245 | |||
| 162604ad24 | |||
| 34e961fb61 | |||
| 422b68846b | |||
| 55f42add15 | |||
| 4ebca13058 | |||
| 279e583701 | |||
| 3afb756993 | |||
| c7378ed7f7 | |||
| 7a4c55bfc9 | |||
| 0f7c89fbb5 | |||
| 9ca9a5ffe1 | |||
| 2dce6eabd5 | |||
| 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 |
264 changed files with 20867 additions and 1664 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -9,6 +9,9 @@ facts_cache/
|
||||||
**/vault.yml
|
**/vault.yml
|
||||||
# Underlay = fabric physique de l'operateur (cluster-global) ; gabarit public seul.
|
# Underlay = fabric physique de l'operateur (cluster-global) ; gabarit public seul.
|
||||||
/underlay.yml
|
/underlay.yml
|
||||||
|
# Contexte actif de CETTE machine (`site:<depot>` ou `locataire:<depot>`) : propre a chaque
|
||||||
|
# poste et a chaque runner, jamais versionne. Voir scripts/contexte.py.
|
||||||
|
/contexte
|
||||||
*.secret
|
*.secret
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
|
|
|
||||||
3803
CHANGELOG.md
3803
CHANGELOG.md
File diff suppressed because it is too large
Load diff
21
CLAUDE.md
21
CLAUDE.md
|
|
@ -4,24 +4,3 @@
|
||||||
|
|
||||||
`AGENTS.md` est la **source d'autorité unique** de ce dépôt. Claude Code doit le lire
|
`AGENTS.md` est la **source d'autorité unique** de ce dépôt. Claude Code doit le lire
|
||||||
et le respecter intégralement avant toute modification.
|
et le respecter intégralement avant toute modification.
|
||||||
|
|
||||||
En cas de contradiction entre `CLAUDE.md` et `AGENTS.md`, **suivre `AGENTS.md`**.
|
|
||||||
|
|
||||||
Ce fichier est volontairement mince : il ne redéclare pas la doctrine (template, SSH,
|
|
||||||
pare-feu, cloud-init, handlers, Makefile, groupes…). Toute cette doctrine vit dans
|
|
||||||
`AGENTS.md`, pour éviter la duplication et les dérives qu'elle provoque.
|
|
||||||
|
|
||||||
## Les cinq règles absolues
|
|
||||||
|
|
||||||
1. **Un seul agent IA** travaille dans ce dépôt à la fois (Codex *ou* Claude, jamais les deux).
|
|
||||||
2. **`instance/inventories/*/hosts.yml` est un artefact GÉNÉRÉ** depuis le plan
|
|
||||||
(`instance/plan/`). On l'édite jamais à la main : on édite le plan, puis
|
|
||||||
`make instancier` → `make instancier-appliquer`.
|
|
||||||
3. **Aucun secret en clair** (mots de passe, clés privées, tokens, certificats) — Vault
|
|
||||||
ou stockage hors dépôt uniquement.
|
|
||||||
4. **Toute action destructive ou risquée exige une confirmation explicite** (ex.
|
|
||||||
`CONFIRMER=true`, `confirm_destructive_action: true`). Sans elle, refuser l'exécution.
|
|
||||||
5. **Rien n'est « prêt » sans validation.** Au minimum `--syntax-check` du playbook
|
|
||||||
touché, `ansible-lint` si disponible, et l'entrée `CHANGELOG.md` correspondante.
|
|
||||||
|
|
||||||
Le détail de chacune de ces règles, et tout le reste, est dans `AGENTS.md`.
|
|
||||||
|
|
|
||||||
237
Makefile
237
Makefile
|
|
@ -21,7 +21,16 @@ export SETOPS_INSTANCE ?= instance
|
||||||
#
|
#
|
||||||
# `?=` : une valeur deja posee dans l'environnement gagne — la GUI et les runners peuvent
|
# `?=` : une valeur deja posee dans l'environnement gagne — la GUI et les runners peuvent
|
||||||
# nommer leurs propres cles sans que ce fichier ait a les connaitre.
|
# nommer leurs propres cles sans que ce fichier ait a les connaitre.
|
||||||
export ANSIBLE_VAULT_IDENTITY_LIST ?= $(shell python3 scripts/voutes.py identites 2>/dev/null)
|
#
|
||||||
|
# VIDE, ON N'EXPORTE RIEN (2026-10-04). Sur un clone sans aucune cle (la CI de la forge),
|
||||||
|
# `voutes.py` ne rend rien, et l'export posait quand meme `ANSIBLE_VAULT_IDENTITY_LIST=`.
|
||||||
|
# Ansible lit cette chaine vide comme un FICHIER de mot de passe, la resout en repertoire
|
||||||
|
# courant et refuse (« can not be a directory ») : `make ci` mourait a sa premiere commande
|
||||||
|
# sur un clone nu. Le poste a toujours des cles, il ne pouvait pas le voir.
|
||||||
|
SETOPS_VOUTES_IDENTITES := $(shell python3 scripts/voutes.py identites 2>/dev/null)
|
||||||
|
ifneq ($(strip $(SETOPS_VOUTES_IDENTITES)),)
|
||||||
|
export ANSIBLE_VAULT_IDENTITY_LIST ?= $(SETOPS_VOUTES_IDENTITES)
|
||||||
|
endif
|
||||||
# Inventaire de l'instance : un seul par instance dans le modèle « séparation par
|
# Inventaire de l'instance : un seul par instance dans le modèle « séparation par
|
||||||
# instance ». Détection rétro-compatible : principal > production > lab.
|
# instance ». Détection rétro-compatible : principal > production > lab.
|
||||||
# Surchargeable : make … SETOPS_INVENTAIRE=chemin/hosts.yml
|
# Surchargeable : make … SETOPS_INVENTAIRE=chemin/hosts.yml
|
||||||
|
|
@ -308,15 +317,32 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire)
|
||||||
python3 scripts/tests/test_inventory_host.py
|
python3 scripts/tests/test_inventory_host.py
|
||||||
python3 scripts/tests/test_raser.py
|
python3 scripts/tests/test_raser.py
|
||||||
python3 scripts/tests/test_raser_resultat.py
|
python3 scripts/tests/test_raser_resultat.py
|
||||||
|
python3 scripts/tests/test_temoins.py
|
||||||
|
python3 scripts/tests/test_genome_colis.py
|
||||||
|
python3 scripts/tests/test_verifier_depot.py
|
||||||
|
python3 scripts/tests/test_apps_nextcloud.py
|
||||||
|
python3 scripts/tests/test_verrou_runner.py
|
||||||
python3 scripts/tests/test_adressage_derive.py
|
python3 scripts/tests/test_adressage_derive.py
|
||||||
python3 scripts/tests/test_gui_intrants.py
|
python3 scripts/tests/test_gui_intrants.py
|
||||||
python3 scripts/tests/test_runbooks.py
|
python3 scripts/tests/test_runbooks.py
|
||||||
python3 scripts/tests/test_portee_console.py
|
python3 scripts/tests/test_portee_console.py
|
||||||
|
python3 scripts/tests/test_forge_cles.py
|
||||||
python3 scripts/tests/test_devis_placement.py
|
python3 scripts/tests/test_devis_placement.py
|
||||||
python3 scripts/tests/test_sonder.py
|
python3 scripts/tests/test_sonder.py
|
||||||
python3 scripts/tests/test_fraicheur_icinga.py
|
python3 scripts/tests/test_fraicheur_icinga.py
|
||||||
python3 scripts/tests/test_sondes_conditionnelles.py
|
python3 scripts/tests/test_sondes_conditionnelles.py
|
||||||
python3 scripts/tests/test_sondes_syntaxe.py
|
python3 scripts/tests/test_sondes_syntaxe.py
|
||||||
|
python3 scripts/tests/test_empreinte_plancher.py
|
||||||
|
python3 scripts/tests/test_expositions_sans_port.py
|
||||||
|
python3 scripts/tests/test_proxmox_fw_externe.py
|
||||||
|
python3 scripts/tests/test_libelles_parefeu.py
|
||||||
|
python3 scripts/tests/test_conditions_site.py
|
||||||
|
python3 scripts/tests/test_sonde_tcp.py
|
||||||
|
python3 scripts/tests/test_restauration.py
|
||||||
|
python3 scripts/tests/test_repertoires_partages.py
|
||||||
|
python3 scripts/tests/test_frontiere_refus.py
|
||||||
|
python3 scripts/tests/test_contexte.py
|
||||||
|
python3 scripts/tests/test_appels_locataire.py
|
||||||
|
|
||||||
# LA SONDE QUI RAPPORTE CE QUI DISTINGUE (2026-08-28). Trois faux diagnostics en une
|
# LA SONDE QUI RAPPORTE CE QUI DISTINGUE (2026-08-28). Trois faux diagnostics en une
|
||||||
# journee, tous dus a l'instrument : `curl` et `bash /dev/tcp` ecrasent « la machine
|
# journee, tous dus a l'instrument : `curl` et `bash /dev/tcp` ecrasent « la machine
|
||||||
|
|
@ -536,8 +562,12 @@ hote-afficher: ansible-runtime ## Affiche tout ce que le plan derive pour un hot
|
||||||
python3 scripts/inventory_host.py --inventaire $(FICHIER_INVENTAIRE) afficher --hote $(HOTE)
|
python3 scripts/inventory_host.py --inventaire $(FICHIER_INVENTAIRE) afficher --hote $(HOTE)
|
||||||
|
|
||||||
appliquer: ansible-runtime ## Applique un groupe a la flotte — GROUPE=<groupe>
|
appliquer: ansible-runtime ## Applique un groupe a la flotte — GROUPE=<groupe>
|
||||||
@if [[ -z "$(GROUPE)" ]]; then \
|
@# `-z "$(GROUPE)"` NE POUVAIT JAMAIS SE DECLENCHER (2026-10-03) : `GROUPE ?=
|
||||||
printf '%s\n' 'Refus: relancer avec GROUPE=nom_groupe.'; \
|
@# $(GROUPE_DEBIAN)` lui donne toujours une valeur. Une commande coupee par un retour
|
||||||
|
@# a la ligne (`make appliquer` seul) a ainsi applique `serveur_debian` a toute la flotte
|
||||||
|
@# de Technolibre. On refuse donc un groupe que l'operateur n'a pas DONNE lui-meme.
|
||||||
|
@if [[ "$(origin GROUPE)" == "file" || -z "$(GROUPE)" ]]; then \
|
||||||
|
printf '%s\n' 'Refus: relancer avec GROUPE=nom_groupe (aucun groupe par defaut ici).'; \
|
||||||
exit 2; \
|
exit 2; \
|
||||||
fi
|
fi
|
||||||
@if [[ ! -f "$(DOSSIER_PLAYBOOKS_GROUPES)/$(GROUPE).yml" ]]; then \
|
@if [[ ! -f "$(DOSSIER_PLAYBOOKS_GROUPES)/$(GROUPE).yml" ]]; then \
|
||||||
|
|
@ -545,7 +575,9 @@ appliquer: ansible-runtime ## Applique un groupe a la flotte — GROUPE=<groupe>
|
||||||
exit 2; \
|
exit 2; \
|
||||||
fi
|
fi
|
||||||
python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) --dependances $(FICHIER_DEPENDANCES) verifier-dependances-groupe --groupe $(GROUPE)
|
python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) --dependances $(FICHIER_DEPENDANCES) verifier-dependances-groupe --groupe $(GROUPE)
|
||||||
ansible-playbook -i $(INVENTAIRE_PRODUCTION) "$(DOSSIER_PLAYBOOKS_GROUPES)/$(GROUPE).yml" --limit '$(GROUPE):&$(GROUPE_HOTES_ACTIFS)'
|
@# `ARGS` comme chez `site-appliquer` (2026-10-03) : simuler (`ARGS="--check --diff"`)
|
||||||
|
@# avant d'appliquer doit etre possible par la meme porte que l'application elle-meme.
|
||||||
|
ansible-playbook -i $(INVENTAIRE_PRODUCTION) "$(DOSSIER_PLAYBOOKS_GROUPES)/$(GROUPE).yml" --limit '$(GROUPE):&$(GROUPE_HOTES_ACTIFS)' $(ARGS)
|
||||||
|
|
||||||
# ATTENDRE PLUTOT QUE CONSTATER (2026-08-27). `deployer` faisait un `ping` unique
|
# ATTENDRE PLUTOT QUE CONSTATER (2026-08-27). `deployer` faisait un `ping` unique
|
||||||
# (`_verifier-acces-hote`). Depuis que `creer-vm` ne guette plus le SSH — il prouve la
|
# (`_verifier-acces-hote`). Depuis que `creer-vm` ne guette plus le SSH — il prouve la
|
||||||
|
|
@ -587,7 +619,7 @@ site: ansible-runtime ## Regenere playbooks/site.yml depuis les couches et le gr
|
||||||
site-verifier: ## Verifie que playbooks/site.yml correspond aux couches declarees
|
site-verifier: ## Verifie que playbooks/site.yml correspond aux couches declarees
|
||||||
python3 scripts/orchestrer.py verifier
|
python3 scripts/orchestrer.py verifier
|
||||||
|
|
||||||
.PHONY: flux flux-verifier
|
.PHONY: flux flux-verifier fiches-site-deposer
|
||||||
cles-recenser: ## Montre ce qui n'existe QUE sur ce poste (sans rien ecrire)
|
cles-recenser: ## Montre ce qui n'existe QUE sur ce poste (sans rien ecrire)
|
||||||
python3 scripts/exporter_cles.py --recenser
|
python3 scripts/exporter_cles.py --recenser
|
||||||
|
|
||||||
|
|
@ -723,8 +755,8 @@ proxmox-fw-activer-vm: ansible-runtime ## Active le pare-feu d'UNE VM en le prou
|
||||||
|
|
||||||
.PHONY: proxmox-fw-eprouver proxmox-fw-activer-vm
|
.PHONY: proxmox-fw-eprouver proxmox-fw-activer-vm
|
||||||
.PHONY: placement-plan sdn-plan sdn-appliquer
|
.PHONY: placement-plan sdn-plan sdn-appliquer
|
||||||
placement-plan: ansible-runtime ## Le noeud, stockage, pont et gabarit du tenant existent-ils sur ce cluster ? (aucune ecriture)
|
placement-plan: ansible-runtime ## Le noeud, stockage, pont et gabarit du tenant existent-ils sur ce cluster ? (aucune ecriture) [TENANT=<depot>]
|
||||||
python3 scripts/devis_placement.py
|
python3 scripts/devis_placement.py $(if $(TENANT),--locataire $(TENANT))
|
||||||
|
|
||||||
sdn-plan: ansible-runtime ## Ecart entre le SDN EVPN (+ sortie des VRF) et son devis (aucune ecriture)
|
sdn-plan: ansible-runtime ## Ecart entre le SDN EVPN (+ sortie des VRF) et son devis (aucune ecriture)
|
||||||
python3 scripts/appliquer_sdn.py
|
python3 scripts/appliquer_sdn.py
|
||||||
|
|
@ -847,6 +879,17 @@ site-intrants: ## Les intrants que le SITE monte expose a ses locataires (derive
|
||||||
site-intrants-verifier: ## Le locataire monte suit-il encore son site ? (aucune ecriture)
|
site-intrants-verifier: ## Le locataire monte suit-il encore son site ? (aucune ecriture)
|
||||||
python3 scripts/site_intrants.py --verifier
|
python3 scripts/site_intrants.py --verifier
|
||||||
|
|
||||||
|
face-reseau-publier: ## Chaque locataire publie sa face reseau pour son site (a commiter chez lui, apres `make flux`)
|
||||||
|
@# LE LOCATAIRE PUBLIE, LE SITE LIT (2026-10-05). Le site lisait les fichiers internes de ses
|
||||||
|
@# locataires ; il lit desormais `face-reseau.yml`. P92 refuse une face perimee.
|
||||||
|
python3 scripts/contexte.py --publier-faces
|
||||||
|
|
||||||
|
fiches-site-deposer: ## Le site depose chez chacun de ses locataires la fiche qu'il lui destine (a commiter chez eux)
|
||||||
|
@# LE SITE DEPOSE, LE LOCATAIRE LIT (2026-10-05). Le runner d'un locataire n'a pas le depot
|
||||||
|
@# de son site : ce que le site lui attribue et lui offre lui parvient par ce fichier,
|
||||||
|
@# `fiche-site.yml`, que l'instancier lit. P91 refuse une fiche perimee.
|
||||||
|
python3 scripts/contexte.py --deposer-fiches
|
||||||
|
|
||||||
.PHONY: vpn-admin-plan vpn-admin-appliquer
|
.PHONY: vpn-admin-plan vpn-admin-appliquer
|
||||||
vpn-admin-plan: ## Acces WireGuard des admins : ce que la frontiere porte face au plan (aucune ecriture)
|
vpn-admin-plan: ## Acces WireGuard des admins : ce que la frontiere porte face au plan (aucune ecriture)
|
||||||
python3 scripts/vpn_admin.py plan
|
python3 scripts/vpn_admin.py plan
|
||||||
|
|
@ -896,6 +939,39 @@ flux-verifier: ## Verifie que le registre des flux correspond aux meta/flux.yml
|
||||||
valider: ansible-runtime ## Passe la recette de validation sur la flotte
|
valider: ansible-runtime ## Passe la recette de validation sur la flotte
|
||||||
ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/valider.yml
|
ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/valider.yml
|
||||||
|
|
||||||
|
.PHONY: restauration-etat restauration-renoncer sauvegarder-maintenant temoins-etat
|
||||||
|
sauvegarder-maintenant: ansible-runtime ## Depose l'etat de chaque noeud TOUT DE SUITE (avant de raser) — HOTE=<hote> optionnel
|
||||||
|
@set -e; $(VAULT_UNE_FOIS) \
|
||||||
|
ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/backup/restauration.yml \
|
||||||
|
-e restauration_action=sauvegarder $(if $(HOTE),--limit "$(HOTE)")
|
||||||
|
|
||||||
|
# LA RESTAURATION DANS LA RECONSTRUCTION (2026-09-30) — la remise elle-meme n'a pas de
|
||||||
|
# cible : chaque role proprietaire la fait au deploiement. Ces deux cibles-ci servent a
|
||||||
|
# VOIR ce qui attend, et a ECARTER un etat qu'on ne veut pas remettre.
|
||||||
|
restauration-etat: ansible-runtime ## Ce qui attend dans le depot, et ce que chaque jeu est devenu — HOTE=<hote> optionnel
|
||||||
|
@set -e; $(VAULT_UNE_FOIS) \
|
||||||
|
ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/backup/restauration.yml \
|
||||||
|
$(if $(HOTE),--limit "$(HOTE)")
|
||||||
|
|
||||||
|
# LES TEMOINS (2026-10-07) — l'etat remis est-il celui d'avant ? Compare l'etat vivant a
|
||||||
|
# l'instantane etiquete au rasage (`sauvegarder-maintenant`). `SOURCE=candidat` compare au
|
||||||
|
# dernier instantane d'avant la naissance du noeud, faute d'etiquette. Lecture seule.
|
||||||
|
temoins-etat: ansible-runtime ## L'etat vivant est-il celui d'avant le rasage ? (lecture seule) — HOTE= SOURCE=candidat optionnels
|
||||||
|
@set -e; $(VAULT_UNE_FOIS) \
|
||||||
|
ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/backup/restauration.yml \
|
||||||
|
-e restauration_action=temoins $(if $(SOURCE),-e restauration_source="$(SOURCE)") \
|
||||||
|
$(if $(HOTE),--limit "$(HOTE)")
|
||||||
|
|
||||||
|
restauration-renoncer: ansible-runtime ## Ecarte l'etat anterieur d'un jeu, sans le remettre — HOTE= JEU= CONFIRMER=true
|
||||||
|
@set -e; \
|
||||||
|
if [[ "$(CONFIRMER)" != "true" || -z "$(HOTE)" || -z "$(JEU)" ]]; then \
|
||||||
|
printf '%s\n' 'Refus: ECARTER un etat anterieur (il sortira de la retention). HOTE=, JEU= et CONFIRMER=true requis.'; \
|
||||||
|
exit 2; \
|
||||||
|
fi; \
|
||||||
|
$(VAULT_UNE_FOIS) \
|
||||||
|
ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/backup/restauration.yml \
|
||||||
|
--limit "$(HOTE)" -e restauration_action=renoncer -e restauration_jeu="$(JEU)"
|
||||||
|
|
||||||
.PHONY: schema
|
.PHONY: schema
|
||||||
schema: ## Regenere docs/audit/schema-plan.json depuis les registres et les validateurs
|
schema: ## Regenere docs/audit/schema-plan.json depuis les registres et les validateurs
|
||||||
python3 scripts/schema_plan.py
|
python3 scripts/schema_plan.py
|
||||||
|
|
@ -1080,14 +1156,23 @@ define VAULT_UNE_FOIS
|
||||||
endef
|
endef
|
||||||
|
|
||||||
|
|
||||||
flotte-creer: _instance-requise ## Cree les VM manquantes de la flotte depuis le plan — PARALLELE=n
|
# LE SITE MATERIALISE UN LOCATAIRE NOMME, D'APRES SA FACE (2026-10-05, docs/conception-contextes.md,
|
||||||
|
# materialisation M2). Avec `TENANT=<depot>`, la liste des machines et leurs parametres de clonage
|
||||||
|
# viennent de `face-reseau.yml` que le locataire publie — rendus a l'octet pres comme
|
||||||
|
# `parametres-proxmox` (P93) — et aucune instance n'est montee. Sans `TENANT`, l'ancienne lecture
|
||||||
|
# de l'instance montee (le poste, le laboratoire).
|
||||||
|
LISTE_ACTIFS = $(if $(TENANT),python3 scripts/contexte.py --hotes-actifs $(TENANT),python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) lister-actifs)
|
||||||
|
PARAMS_CLONAGE = $(if $(TENANT),python3 scripts/contexte.py --parametres-clonage $(TENANT) --hote,python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) parametres-proxmox --hote)
|
||||||
|
INSTANCE_OU_LOCATAIRE = $(if $(TENANT),,_instance-requise)
|
||||||
|
|
||||||
|
flotte-creer: $(INSTANCE_OU_LOCATAIRE) ## Cree les VM manquantes de la flotte depuis le plan — PARALLELE=n
|
||||||
@set -e; \
|
@set -e; \
|
||||||
if [[ "$(CONFIRMER)" != "true" ]]; then \
|
if [[ "$(CONFIRMER)" != "true" ]]; then \
|
||||||
printf '%s\n' 'Refus: creation de TOUTES les VM actives du plan (clone Proxmox).'; \
|
printf '%s\n' 'Refus: creation de TOUTES les VM actives du plan (clone Proxmox).'; \
|
||||||
printf '%s\n' 'Relancer avec CONFIRMER=true.'; \
|
printf '%s\n' 'Relancer avec CONFIRMER=true.'; \
|
||||||
exit 2; \
|
exit 2; \
|
||||||
fi; \
|
fi; \
|
||||||
hotes="$$(python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) lister-actifs)"; \
|
hotes="$$($(LISTE_ACTIFS))"; \
|
||||||
if [[ -z "$$hotes" ]]; then printf '%s\n' 'Refus: aucun hote actif dans le plan.'; exit 2; fi; \
|
if [[ -z "$$hotes" ]]; then printf '%s\n' 'Refus: aucun hote actif dans le plan.'; exit 2; fi; \
|
||||||
par="$(PARALLELE)"; par="$${par:-4}"; \
|
par="$(PARALLELE)"; par="$${par:-4}"; \
|
||||||
tmp="$$(mktemp -d)"; trap 'rm -rf "$$tmp"' EXIT; \
|
tmp="$$(mktemp -d)"; trap 'rm -rf "$$tmp"' EXIT; \
|
||||||
|
|
@ -1096,7 +1181,7 @@ flotte-creer: _instance-requise ## Cree les VM manquantes de la flotte depuis le
|
||||||
n=0; \
|
n=0; \
|
||||||
for h in $$hotes; do \
|
for h in $$hotes; do \
|
||||||
printf '\n=== Creation VM: %s ===\n' "$$h"; \
|
printf '\n=== Creation VM: %s ===\n' "$$h"; \
|
||||||
( $(MAKE) creer-vm HOTE="$$h" > "$$tmp/$$h.log" 2>&1; printf '%s' "$$?" > "$$tmp/$$h.rc" ) & \
|
( $(MAKE) creer-vm HOTE="$$h" $(if $(TENANT),TENANT="$(TENANT)") > "$$tmp/$$h.log" 2>&1; printf '%s' "$$?" > "$$tmp/$$h.rc" ) & \
|
||||||
n=$$((n+1)); \
|
n=$$((n+1)); \
|
||||||
if (( n >= par )); then wait -n || true; n=$$((n-1)); fi; \
|
if (( n >= par )); then wait -n || true; n=$$((n-1)); fi; \
|
||||||
done; \
|
done; \
|
||||||
|
|
@ -1111,6 +1196,13 @@ flotte-creer: _instance-requise ## Cree les VM manquantes de la flotte depuis le
|
||||||
if (( echec )); then printf '\nAu moins une VM n a pas ete creee — rien ne continue.\n'; exit 2; fi; \
|
if (( echec )); then printf '\nAu moins une VM n a pas ete creee — rien ne continue.\n'; exit 2; fi; \
|
||||||
printf '\nToutes les VM actives sont creees.\n'
|
printf '\nToutes les VM actives sont creees.\n'
|
||||||
|
|
||||||
|
locataire-creer: ## Le SITE cree les VM d'un locataire d'apres sa face reseau — TENANT=<depot> CONFIRMER=true [PARALLELE=n]
|
||||||
|
@if [[ -z "$(TENANT)" ]]; then printf '%s\n' 'Refus: TENANT=<depot du locataire> requis.'; exit 2; fi
|
||||||
|
@$(MAKE) --no-print-directory flotte-creer TENANT="$(TENANT)" CONFIRMER="$(CONFIRMER)" PARALLELE="$(PARALLELE)"
|
||||||
|
locataire-raser: ## DESTRUCTIF : le SITE rase les VM d'un locataire d'apres sa face reseau — TENANT=<depot> CONFIRMER=true INSTANCE=<nom court> [HOTE=h]
|
||||||
|
@if [[ -z "$(TENANT)" ]]; then printf '%s\n' 'Refus: TENANT=<depot du locataire> requis.'; exit 2; fi
|
||||||
|
@$(MAKE) --no-print-directory raser TENANT="$(TENANT)" CONFIRMER="$(CONFIRMER)" INSTANCE="$(INSTANCE)" HOTE="$(HOTE)"
|
||||||
|
|
||||||
# --- LES MACHINES DU SITE -----------------------------------------------------
|
# --- LES MACHINES DU SITE -----------------------------------------------------
|
||||||
#
|
#
|
||||||
# UN SITE N'EST PAS UN PLAN. Un tenant se derive de son `index` ; un site n'en a pas et
|
# UN SITE N'EST PAS UN PLAN. Un tenant se derive de son `index` ; un site n'en a pas et
|
||||||
|
|
@ -1132,6 +1224,10 @@ site-inventaire: ## L'inventaire dynamique du site, tel qu'Ansible le voit
|
||||||
|
|
||||||
.PHONY: site-appliquer
|
.PHONY: site-appliquer
|
||||||
site-appliquer: ## Applique un role aux machines du site — GROUPE=<serveur_ops_site|...>
|
site-appliquer: ## Applique un role aux machines du site — GROUPE=<serveur_ops_site|...>
|
||||||
|
@if [[ "$(origin GROUPE)" == "file" || -z "$(GROUPE)" ]]; then \
|
||||||
|
printf '%s\n' 'Refus: relancer avec GROUPE=nom_groupe (aucun groupe par defaut ici).'; \
|
||||||
|
exit 2; \
|
||||||
|
fi
|
||||||
@# `GROUPE` a un defaut global (GROUPE_DEBIAN) : tester s'il est VIDE ne protege de
|
@# `GROUPE` a un defaut global (GROUPE_DEBIAN) : tester s'il est VIDE ne protege de
|
||||||
@# rien, ce garde-fou-la ne pourrait jamais se declencher. Le vrai risque, observe :
|
@# rien, ce garde-fou-la ne pourrait jamais se declencher. Le vrai risque, observe :
|
||||||
@# lancer un playbook de TENANT contre l'inventaire du SITE. Ansible n'y trouve aucun
|
@# lancer un playbook de TENANT contre l'inventaire du SITE. Ansible n'y trouve aucun
|
||||||
|
|
@ -1284,6 +1380,26 @@ genome-pousser: ## Le runner du site pousse le genome sur la forge du site — D
|
||||||
SETOPS_DEPOT="$(DEPOT)" ansible-playbook -i scripts/site_inventaire.py \
|
SETOPS_DEPOT="$(DEPOT)" ansible-playbook -i scripts/site_inventaire.py \
|
||||||
playbooks/maintenance/genome_pousser.yml "$${supp[@]}" $(ARGS)
|
playbooks/maintenance/genome_pousser.yml "$${supp[@]}" $(ARGS)
|
||||||
|
|
||||||
|
.PHONY: forge-cles
|
||||||
|
forge-cles: ## Le site enregistre sur le depot de chaque locataire la cle de lecture que sa face publie — CONFIRMER=true pour ecrire
|
||||||
|
@set -e; \
|
||||||
|
voute="$$(dirname "$$(readlink -f underlay.yml)")/underlay.vault.yml"; \
|
||||||
|
supp=(); [[ -f "$$voute" ]] && supp+=( -e "@$$voute" ); \
|
||||||
|
CONFIRMER="$(CONFIRMER)" ansible-playbook -i scripts/site_inventaire.py \
|
||||||
|
playbooks/maintenance/forge_cles.yml "$${supp[@]}" $(ARGS)
|
||||||
|
|
||||||
|
.PHONY: forge-comptes
|
||||||
|
forge-comptes: ## Les comptes de la forge du site : un par mainteneur (plan du site), et le lecteur de la console — CONFIRMER=true pour ecrire
|
||||||
|
@set -e; \
|
||||||
|
voute="$$(dirname "$$(readlink -f underlay.yml)")/underlay.vault.yml"; \
|
||||||
|
supp=(); [[ -f "$$voute" ]] && supp+=( -e "@$$voute" ); \
|
||||||
|
CONFIRMER="$(CONFIRMER)" ansible-playbook -i scripts/site_inventaire.py \
|
||||||
|
playbooks/maintenance/forge_comptes.yml "$${supp[@]}" $(ARGS)
|
||||||
|
|
||||||
|
.PHONY: forge-etancheite
|
||||||
|
forge-etancheite: ansible-runtime ## La forge du site laisse-t-elle chaque runner lire ses depots, et personne d'autre ? (mesure, aucune ecriture)
|
||||||
|
python3 scripts/forge_etancheite.py
|
||||||
|
|
||||||
.PHONY: depots-perimes
|
.PHONY: depots-perimes
|
||||||
depots-perimes: ansible-runtime _instance-requise ## Depots qu'un runner ne declare plus : regarde, et retire avec CONFIRMER=true
|
depots-perimes: ansible-runtime _instance-requise ## Depots qu'un runner ne declare plus : regarde, et retire avec CONFIRMER=true
|
||||||
@# DESTRUCTIF SUR CONFIRMATION SEULEMENT. Sans `CONFIRMER=true`, ce geste REGARDE :
|
@# DESTRUCTIF SUR CONFIRMATION SEULEMENT. Sans `CONFIRMER=true`, ce geste REGARDE :
|
||||||
|
|
@ -1372,8 +1488,8 @@ _attendre-flotte: ansible-runtime
|
||||||
printf 'Flotte joignable.\n'
|
printf 'Flotte joignable.\n'
|
||||||
|
|
||||||
.PHONY: reconstruire
|
.PHONY: reconstruire
|
||||||
raser: ansible-runtime _instance-requise ## DESTRUCTIF : detruit les VM derivees du plan — exige CONFIRMER=true ET INSTANCE=<nom>
|
raser: ansible-runtime $(INSTANCE_OU_LOCATAIRE) ## DESTRUCTIF : detruit les VM derivees du plan — exige CONFIRMER=true ET INSTANCE=<nom> [TENANT=<depot>]
|
||||||
@python3 scripts/raser.py $(if $(INSTANCE),--instance $(INSTANCE)) $(if $(HOTE),--hote $(HOTE)) $(if $(filter true,$(CONFIRMER)),--confirmer)
|
@python3 scripts/raser.py $(if $(TENANT),--locataire $(TENANT)) $(if $(INSTANCE),--instance $(INSTANCE)) $(if $(HOTE),--hote $(HOTE)) $(if $(filter true,$(CONFIRMER)),--confirmer)
|
||||||
|
|
||||||
# `make flux` D'ABORD, et ce n'est pas une precaution : sans lui, `instance/flux-genere/`
|
# `make flux` D'ABORD, et ce n'est pas une precaution : sans lui, `instance/flux-genere/`
|
||||||
# est VIDE et le socle deploie nftables en `policy drop` SANS AUCUNE REGLE DERIVEE. La
|
# est VIDE et le socle deploie nftables en `policy drop` SANS AUCUNE REGLE DERIVEE. La
|
||||||
|
|
@ -1394,8 +1510,70 @@ reconstruire: _instance-requise ## Reconstruit un ecosysteme depuis zero : VM pu
|
||||||
$(MAKE) flux; \
|
$(MAKE) flux; \
|
||||||
$(MAKE) flotte-creer CONFIRMER=true; \
|
$(MAKE) flotte-creer CONFIRMER=true; \
|
||||||
$(MAKE) _attendre-flotte; \
|
$(MAKE) _attendre-flotte; \
|
||||||
$(MAKE) _amorcer-socle; \
|
$(MAKE) monter-flotte CONFIRMER=true
|
||||||
$(MAKE) deployer-tout CONFIRMER=true
|
|
||||||
|
# MONTER UNE FLOTTE DONT LES VM EXISTENT DEJA (2026-09-30). C'est la seconde moitie de
|
||||||
|
# `reconstruire`, et TOUT ce que le runner d'un locataire peut en faire : il ne cree pas de VM
|
||||||
|
# (l'API du cluster est a l'hebergeur), il les recoit, armees, et les monte. Jusqu'ici cette
|
||||||
|
# sequence vivait dans un script ecrit a la main dans /tmp du runner — deux reconstructions
|
||||||
|
# l'ont portee, et aucune trace ne la gardait.
|
||||||
|
#
|
||||||
|
# `set -e` : la premiere etape en echec arrete tout. Chaque etape est horodatee, pour qu'un
|
||||||
|
# journal dise ou l'on en etait sans relire dix mille lignes d'Ansible. `valider` fait partie
|
||||||
|
# de la sequence : une flotte sans recette n'est pas montee, elle est deployee.
|
||||||
|
.PHONY: monter-flotte
|
||||||
|
monter-flotte: _instance-requise ## Monte une flotte deja creee : flux, AC et DNS, tout le reste, recette — CONFIRMER=true
|
||||||
|
@set -e; \
|
||||||
|
if [[ "$(CONFIRMER)" != "true" ]]; then \
|
||||||
|
printf '%s\n' 'Refus: MONTER la flotte (deploiement complet puis recette). Relancer avec CONFIRMER=true.'; \
|
||||||
|
exit 2; \
|
||||||
|
fi; \
|
||||||
|
etape() { printf '\n=== ETAPE %s %s\n' "$$1" "$$(date +%T)"; }; \
|
||||||
|
etape flux; $(MAKE) flux; \
|
||||||
|
etape _amorcer-socle; $(MAKE) _amorcer-socle; \
|
||||||
|
etape deployer-tout; $(MAKE) deployer-tout CONFIRMER=true; \
|
||||||
|
etape valider; $(MAKE) valider; \
|
||||||
|
printf '\n=== FLOTTE MONTEE %s\n' "$$(date +%T)"
|
||||||
|
|
||||||
|
# RECONSTRUIRE UN LOCATAIRE DE BOUT EN BOUT, DEPUIS LE POSTE (2026-09-30). Les deux
|
||||||
|
# reconstructions du 2026-09-30 passaient par cinq endroits et des commandes tapees a la
|
||||||
|
# main ; la sequence vit desormais dans `scripts/reconstruire_locataire.py` (voir son
|
||||||
|
# en-tete). `TENANT=` designe l'ecosysteme en toutes lettres : le nom qu'exige `raser` en est
|
||||||
|
# derive, pas redemande. L'armement marque une pause, sauf `ARMER=oui`. `DEPUIS=<etape>` reprend apres un
|
||||||
|
# arret. Le journal complet va dans `<locataire>/logs/`.
|
||||||
|
.PHONY: reconstruire-locataire
|
||||||
|
reconstruire-locataire: ## Rase et reconstruit un locataire, du site a la recette, etat remis — TENANT=OPS-X CONFIRMER=true
|
||||||
|
@if [[ -z "$(TENANT)" ]]; then \
|
||||||
|
printf '%s\n' 'Refus: TENANT=<OPS-X> requis (le dossier du locataire, en toutes lettres).'; \
|
||||||
|
exit 2; \
|
||||||
|
fi
|
||||||
|
python3 scripts/reconstruire_locataire.py --tenant "$(TENANT)" \
|
||||||
|
$(if $(filter true,$(CONFIRMER)),--confirmer) $(if $(DEPUIS),--depuis "$(DEPUIS)") \
|
||||||
|
$(if $(filter oui,$(ARMER)),--armer)
|
||||||
|
|
||||||
|
# LE PARE-FEU PROXMOX D'UNE FLOTTE RECONSTRUITE (2026-09-30). Une VM clonee nait sans les
|
||||||
|
# options de pare-feu : chaque reconstruction les perd. La procedure (une VM a la fois,
|
||||||
|
# matrice avant/apres, Icinga) etait rejouee par une boucle tapee a la main ; `--flotte` la
|
||||||
|
# porte, avec la regle qui la rend sure : arret au premier refus.
|
||||||
|
#
|
||||||
|
# PAR LES SONDES DEPUIS LE 2026-09-30 (`--sondes`) : la matrice prenait une vingtaine de
|
||||||
|
# minutes en fin de reconstruction. Chaque VM teste desormais chaque minute ses flux promis
|
||||||
|
# (sonde `connectivite`) ; on active tout d'un coup et on lit Icinga. La matrice reste pour
|
||||||
|
# le diagnostic : `python3 scripts/eprouver_parefeu.py --instance OPS-X --hote <vm> --plan`.
|
||||||
|
#
|
||||||
|
# DEPUIS LE POSTE : la matrice se joue avec les acces du LOCATAIRE, l'activation passe par
|
||||||
|
# le runner du SITE, seul a joindre l'API du cluster. Le poste est le seul a tenir les deux.
|
||||||
|
.PHONY: parefeu-verifier-flotte parefeu-activer-flotte
|
||||||
|
parefeu-verifier-flotte: ## La sonde `connectivite` est-elle saine sur chaque VM (prealable a l'activation) — INSTANCE=OPS-X
|
||||||
|
@if [[ -z "$(INSTANCE)" ]]; then printf '%s\n' 'Refus: INSTANCE=<OPS-X> requis.'; exit 2; fi
|
||||||
|
python3 scripts/eprouver_parefeu.py --instance "$(INSTANCE)" --flotte --sondes --plan
|
||||||
|
|
||||||
|
parefeu-activer-flotte: ## Active le pare-feu Proxmox de toutes les VM, juge par la sonde `connectivite` — INSTANCE=OPS-X CONFIRMER=true
|
||||||
|
@if [[ -z "$(INSTANCE)" || "$(CONFIRMER)" != "true" ]]; then \
|
||||||
|
printf '%s\n' 'Refus: INSTANCE=<OPS-X> et CONFIRMER=true requis (active le pare-feu de TOUTES les VM, une a une).'; \
|
||||||
|
exit 2; \
|
||||||
|
fi
|
||||||
|
python3 scripts/eprouver_parefeu.py --instance "$(INSTANCE)" --flotte --sondes --activer
|
||||||
|
|
||||||
# « Go ahead, make my day. » — LE bouton rouge : alias de reconstruire (Clint Eastwood).
|
# « Go ahead, make my day. » — LE bouton rouge : alias de reconstruire (Clint Eastwood).
|
||||||
# Cree toutes les VM puis deploie tout, en une commande. Garde CONFIRMER=true.
|
# Cree toutes les VM puis deploie tout, en une commande. Garde CONFIRMER=true.
|
||||||
|
|
@ -1420,8 +1598,12 @@ _amorcer-socle: ansible-runtime _instance-requise
|
||||||
myDay: reconstruire ## Alias strict de `reconstruire` (meme cible, memes gardes)
|
myDay: reconstruire ## Alias strict de `reconstruire` (meme cible, memes gardes)
|
||||||
|
|
||||||
deployer-groupe: ## Deploie un seul groupe sur toute la flotte — GROUPE=<groupe>
|
deployer-groupe: ## Deploie un seul groupe sur toute la flotte — GROUPE=<groupe>
|
||||||
@if [[ -z "$(GROUPE)" ]]; then \
|
@# `-z "$(GROUPE)"` NE POUVAIT JAMAIS SE DECLENCHER (2026-10-03) : `GROUPE ?=
|
||||||
printf '%s\n' 'Refus: relancer avec GROUPE=nom_groupe.'; \
|
@# $(GROUPE_DEBIAN)` lui donne toujours une valeur. Une commande coupee par un retour
|
||||||
|
@# a la ligne (`make appliquer` seul) a ainsi applique `serveur_debian` a toute la flotte
|
||||||
|
@# de Technolibre. On refuse donc un groupe que l'operateur n'a pas DONNE lui-meme.
|
||||||
|
@if [[ "$(origin GROUPE)" == "file" || -z "$(GROUPE)" ]]; then \
|
||||||
|
printf '%s\n' 'Refus: relancer avec GROUPE=nom_groupe (aucun groupe par defaut ici).'; \
|
||||||
exit 2; \
|
exit 2; \
|
||||||
fi
|
fi
|
||||||
$(MAKE) appliquer GROUPE="$(GROUPE)"
|
$(MAKE) appliquer GROUPE="$(GROUPE)"
|
||||||
|
|
@ -1456,18 +1638,6 @@ verifier-deploiement: ansible-runtime ## Verifie l'etat de la flotte apres deplo
|
||||||
ansible-playbook -i $(INVENTAIRE_PRODUCTION) "$$playbook" --limit "$(HOTE)" --check --diff; \
|
ansible-playbook -i $(INVENTAIRE_PRODUCTION) "$$playbook" --limit "$(HOTE)" --check --diff; \
|
||||||
done
|
done
|
||||||
|
|
||||||
# LE CLONAGE SE SERIALISE PAR STOCKAGE, et c'est la granularite de Proxmox
|
|
||||||
# lui-meme : il tient un verrou PAR STOCKAGE pendant l'allocation du disque. Deux
|
|
||||||
# clones simultanes sur le meme stockage se le disputent, et celui qui attend trop
|
|
||||||
# rend « clone failed: can't lock file '/var/lock/pve-manager/pve-storage-<nom>' -
|
|
||||||
# got timeout » — un message qui nomme le verrou, jamais le parallelisme qui l'a
|
|
||||||
# provoque. A deux VM rien ne parait ; a cinq, une creation sur cinq echoue, et la
|
|
||||||
# reconstruction s'arrete sans que rien ne dise pourquoi elle tenait a deux.
|
|
||||||
#
|
|
||||||
# LA CLE PORTE LE NOEUD ET LE STOCKAGE, donc deux stockages distincts clonent
|
|
||||||
# TOUJOURS en parallele : `flotte-creer PARALLELE=n` garde tout son sens, et seule
|
|
||||||
# la section qui contend est prise un a la fois, dans l'ordre ou la sequence du
|
|
||||||
# plan la demande.
|
|
||||||
cloner-vm: ansible-runtime ## Clone une VM depuis le gabarit dore — HOTE=<nom> VMID=<id>
|
cloner-vm: ansible-runtime ## Clone une VM depuis le gabarit dore — HOTE=<nom> VMID=<id>
|
||||||
@if [[ -z "$(HOTE)" || -z "$(VMID)" ]]; then \
|
@if [[ -z "$(HOTE)" || -z "$(VMID)" ]]; then \
|
||||||
printf '%s\n' 'Refus: relancer avec HOTE=nom VMID=id_clone.'; \
|
printf '%s\n' 'Refus: relancer avec HOTE=nom VMID=id_clone.'; \
|
||||||
|
|
@ -1501,7 +1671,7 @@ cloner-vm: ansible-runtime ## Clone une VM depuis le gabarit dore — HOTE=<nom>
|
||||||
-e proxmox_clone_nom="$(HOTE)" \
|
-e proxmox_clone_nom="$(HOTE)" \
|
||||||
-e proxmox_clone_vmid="$(VMID)" \
|
-e proxmox_clone_vmid="$(VMID)" \
|
||||||
-e proxmox_clone_ipconfig0="$$ipconfig" \
|
-e proxmox_clone_ipconfig0="$$ipconfig" \
|
||||||
-e proxmox_clone_pool="$(if $(POOL),$(POOL),$$(python3 scripts/devis_proxmox_pools.py --pool-actif))" \
|
-e proxmox_clone_pool="$(if $(POOL),$(POOL),$$(python3 scripts/devis_proxmox_pools.py $(if $(TENANT),--pool-du-locataire $(TENANT),--pool-actif)))" \
|
||||||
); \
|
); \
|
||||||
[[ -n "$(VMID_MODELE)" ]] && extra_vars+=( -e proxmox_clone_vmid_modele="$(VMID_MODELE)" ); \
|
[[ -n "$(VMID_MODELE)" ]] && extra_vars+=( -e proxmox_clone_vmid_modele="$(VMID_MODELE)" ); \
|
||||||
[[ -n "$(NOEUD_PROXMOX)" ]] && extra_vars+=( -e proxmox_clone_noeud="$(NOEUD_PROXMOX)" ); \
|
[[ -n "$(NOEUD_PROXMOX)" ]] && extra_vars+=( -e proxmox_clone_noeud="$(NOEUD_PROXMOX)" ); \
|
||||||
|
|
@ -1542,16 +1712,15 @@ cloner-vm: ansible-runtime ## Clone une VM depuis le gabarit dore — HOTE=<nom>
|
||||||
;; \
|
;; \
|
||||||
esac; \
|
esac; \
|
||||||
fi; \
|
fi; \
|
||||||
verrou="$${TMPDIR:-/tmp}/setops-clone-$$(printf '%s_%s' '$(NOEUD_PROXMOX)' '$(STOCKAGE_PROXMOX)' | tr -c 'A-Za-z0-9._-' '_').lock"; \
|
ansible-playbook -i localhost, $(PLAYBOOK_PROXMOX_CLONER_VM) "$${vault_args[@]}" "$${extra_vars[@]}"
|
||||||
flock "$$verrou" ansible-playbook -i localhost, $(PLAYBOOK_PROXMOX_CLONER_VM) "$${vault_args[@]}" "$${extra_vars[@]}"
|
|
||||||
|
|
||||||
creer-vm: _instance-requise ## Cree une VM et attend qu'elle soit joignable — HOTE=<nom>
|
creer-vm: $(INSTANCE_OU_LOCATAIRE) ## Cree une VM et attend qu'elle soit joignable — HOTE=<nom> [TENANT=<depot>]
|
||||||
@set -e; \
|
@set -e; \
|
||||||
if [[ -z "$(HOTE)" ]]; then \
|
if [[ -z "$(HOTE)" ]]; then \
|
||||||
printf '%s\n' 'Refus: relancer avec HOTE=nom_hote (declare dans le plan).'; \
|
printf '%s\n' 'Refus: relancer avec HOTE=nom_hote (declare dans le plan).'; \
|
||||||
exit 2; \
|
exit 2; \
|
||||||
fi; \
|
fi; \
|
||||||
params="$$(python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) parametres-proxmox --hote $(HOTE))"; \
|
params="$$($(PARAMS_CLONAGE) $(HOTE))"; \
|
||||||
eval "$$params"; \
|
eval "$$params"; \
|
||||||
export SETOPS_CLES_AMORCAGE; \
|
export SETOPS_CLES_AMORCAGE; \
|
||||||
$(MAKE) cloner-vm \
|
$(MAKE) cloner-vm \
|
||||||
|
|
@ -1592,7 +1761,7 @@ creer-vm: _instance-requise ## Cree une VM et attend qu'elle soit joignable —
|
||||||
@# ATTENDRE LA DISPONIBILITE est un autre geste, deplace chez `deployer` : cloud-init
|
@# ATTENDRE LA DISPONIBILITE est un autre geste, deplace chez `deployer` : cloud-init
|
||||||
@# et dpkg se guettent juste avant de configurer, par celui qui va configurer.
|
@# et dpkg se guettent juste avant de configurer, par celui qui va configurer.
|
||||||
@if [[ "$(ATTENDRE)" != "false" ]]; then \
|
@if [[ "$(ATTENDRE)" != "false" ]]; then \
|
||||||
params="$$(python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) parametres-proxmox --hote $(HOTE))"; \
|
params="$$($(PARAMS_CLONAGE) $(HOTE))"; \
|
||||||
eval "$$params"; \
|
eval "$$params"; \
|
||||||
python3 scripts/attendre_materialisation.py \
|
python3 scripts/attendre_materialisation.py \
|
||||||
--vmid "$$SETOPS_VMID" --ip "$$SETOPS_IP" --hote "$(HOTE)" \
|
--vmid "$$SETOPS_VMID" --ip "$$SETOPS_IP" --hote "$(HOTE)" \
|
||||||
|
|
|
||||||
120
docs/audit/preuve-2026-09-30.md
Normal file
120
docs/audit/preuve-2026-09-30.md
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
# Preuve de conformite — Set-OPS — 2026-09-30
|
||||||
|
|
||||||
|
> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer
|
||||||
|
> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;
|
||||||
|
> aucune validation n'est reimplementee ici. Voir le mode d'emploi :
|
||||||
|
> [`docs/audit/README.md`](README.md), et le registre trace :
|
||||||
|
> [`docs/audit/affirmations.md`](affirmations.md).
|
||||||
|
|
||||||
|
- **Instance** : `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance` — inventaire `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance/inventories/principal/hosts.yml`
|
||||||
|
- **Verdict** : ❌ NON CONFORME (81 OK · 1 echec · 1 saute)
|
||||||
|
|
||||||
|
## Preuves
|
||||||
|
|
||||||
|
| # | Preuve | Affirmations | Statut | Detail |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | } \| to_nice_json }}`. |
|
||||||
|
| P02 | Tests unitaires (inventaire, raser, ecritures du plan, rendu du GUI) | — | ✅ OK | OK |
|
||||||
|
| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 4 instance(s) verifiee(s) — instance-ci-1646753, OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre : plan et inventaire applique coincident. |
|
||||||
|
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
|
||||||
|
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
|
||||||
|
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
|
||||||
|
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check), 1 nom(s) surveille(s) sans reference orpheline. |
|
||||||
|
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 42 groupes classes, aucun cycle, aucune arete en arriere ; playbooks/site.yml a jour. |
|
||||||
|
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 40 rôles, 121 flux, schéma + matrice OK. |
|
||||||
|
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
|
||||||
|
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_resolveur_site |
|
||||||
|
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
|
||||||
|
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
|
||||||
|
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
|
||||||
|
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
|
||||||
|
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ⚪ SAUTE | Voute chiffree sans ANSIBLE_VAULT_PASSWORD_FILE (prerequis AFF-026). |
|
||||||
|
| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. |
|
||||||
|
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 32 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) |
|
||||||
|
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 30 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
|
||||||
|
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) et 7 zone(s) de site : adressage 100% derive du seed index. |
|
||||||
|
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 5 instance(s) federee(s), aucun index en collision. |
|
||||||
|
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). |
|
||||||
|
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 14 reseau(x), aucune collision avec la plage tenant. |
|
||||||
|
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | note : serveur_powerdns declare un port `derive` que le plan du site ne resout pas — aucune regle emise. |
|
||||||
|
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 2 tenant(s), 40 groupe(s), 108 regle(s). |
|
||||||
|
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 13 hote(s) x 6 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). |
|
||||||
|
| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. |
|
||||||
|
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 35 VM placee(s), aucun nom ni VMID en collision. |
|
||||||
|
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 34 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 23, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
|
||||||
|
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 2 zone(s), 12 VNet(s), 12 sous-reseau(x), aucune collision. |
|
||||||
|
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 77 scripts expliques et atteignables, 146 cibles make documentees, 69 roles avec README. |
|
||||||
|
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (142 cle(s) declaree(s) par l'instance). |
|
||||||
|
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 44 revendication(s) de port, aucune collision entre roles co-localises (35 groupes). |
|
||||||
|
| P34 | Chaque document declare son lecteur | — | ✅ OK | 49 document(s) declarent leur lecteur (44 genere(s) exempte(s)). |
|
||||||
|
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 4 application(s) exigeant une base l'ont toutes (3 entree(s) au registre). |
|
||||||
|
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 7 hote(s) de l'ecosysteme et 3 du site detiennent de l'etat, tous porteurs de `client_backup` (8 groupe(s) au catalogue). |
|
||||||
|
| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. |
|
||||||
|
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 42 role(s) serveur/client tous nommes, 42 groupe(s) cite(s) en table existent tous. |
|
||||||
|
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 97 page(s) de wiki toutes atteignables. |
|
||||||
|
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
|
||||||
|
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 73 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
|
||||||
|
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (instance-ci-1646753/production, OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre |
|
||||||
|
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 9 machine(s) du plan retrouvees, 202 regle(s) du site. |
|
||||||
|
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 integration(s) appliquent leur serveur avant leurs clients. |
|
||||||
|
| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). |
|
||||||
|
| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos |
|
||||||
|
| P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). |
|
||||||
|
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 101 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. |
|
||||||
|
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (139 lignes). |
|
||||||
|
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 309 regles `pass`), tous non consignes et tous motives. |
|
||||||
|
| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 |
|
||||||
|
| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. |
|
||||||
|
| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 13 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b |
|
||||||
|
| P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 9 role(s) applique(s), aucun secret de tenant reclame. |
|
||||||
|
| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 13 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 13. |
|
||||||
|
| P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. |
|
||||||
|
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (83 preuves, 69 roles, 42 groupes). |
|
||||||
|
| P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. |
|
||||||
|
| P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). |
|
||||||
|
| P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `3fe7e3c` (publie le 2026-09-29). |
|
||||||
|
| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 56 champ(s) sur 7 registres ; il couvre tout ce que les plans reels contiennent, et la FORME de chaque champ (scalaire / objet / table) corresp |
|
||||||
|
| P62 | Schema du plan : il decrit tout ce que le MOTEUR accepte | AFF-033 | ✅ OK | Les 4 validateurs n'acceptent aucun champ que le schema ignore (applications:8, bases_donnees:4, domaines_publics:9, serveurs:3 champ(s) lus par validateur). |
|
||||||
|
| P63 | cloud-init nait avec la VM et ne lui survit pas | — | ✅ OK | cloud-init est au gabarit (la premiere seconde), absent du socle (pas de va-et-vient), et retire par le durcissement — avec la garde qui verifie que le reseau s |
|
||||||
|
| P64 | Sondes de supervision : declarees ET deposees | — | ✅ OK | 47 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_journal/journaux, client_metrique/metriques, client_pki/certificat, client_s |
|
||||||
|
| P65 | Depots tiers : demandes au cache, jamais en HTTPS direct | — | ✅ OK | 4 depot(s) tiers relaye(s) par le cache, aucun role ne les vise en https:// ecrit en dur. |
|
||||||
|
| P66 | Clients OIDC : chaque URI vise un nom que le plan expose | — | ✅ OK | 4 client(s) OIDC, toutes leurs URI visent un FQDN que le plan expose (7 exposition(s)). |
|
||||||
|
| P67 | Nom public : le service porte celui du plan, pas celui du role | — | ✅ OK | 14 service(s) expose(s) portent le nom du plan, sur 2 inventaire(s) : instance, SITE. |
|
||||||
|
| P68 | Cle de depot telechargee : mesuree avant d'etre utilisee | — | ✅ OK | 5 role(s) telechargent une cle de depot, tous la mesurent avant de s'en servir. |
|
||||||
|
| P69 | Amorcage d'un tenant : l'adresse designe le site REEL | — | ✅ OK | 2 adresse(s) d'amorcage designent bien une machine du site. |
|
||||||
|
| P70 | Depot de binaires : il tient tout ce que les roles vont chercher | — | ✅ OK | 6 artefact(s) direct(s) tenus par le depot du site. |
|
||||||
|
| P71 | Pool du site : le genome ne nait pas chez un tenant | — | ✅ OK | `site-creer` nomme `--pool-site` ; le pool du genome ne peut plus etre celui d'un tenant. |
|
||||||
|
| P72 | Annuaire : aucun service ne se lie avec le compte du maitre | — | ✅ OK | 5 role(s) consultent l'annuaire, chacun avec SON compte de service ; seul `amorcage_acces` garde celui d'administration, et il provisionne au lieu de consommer. |
|
||||||
|
| P73 | Le locataire designe les services de son site REEL | — | ✅ OK | CONFORME : 10 intrant(s) du locataire concordent avec ce que le site expose (1 non declare(s), donc derive(s) ou non utilise(s)). |
|
||||||
|
| P74 | Gabarit dore : une seule declaration, au plan du site | — | ✅ OK | Gabarit declare une seule fois : VMID 9006 « modeleSetOPS-minimal », precedent 99998. |
|
||||||
|
| P75 | Les parametres de clonage traversent les trois maillons | — | ✅ OK | 14 parametre(s) de clonage, tous emis par l'inventaire. |
|
||||||
|
| P76 | Tout gabarit de role se rend vraiment | — | ✅ OK | 185 gabarits de role : tous se rendent. |
|
||||||
|
| P77 | Panneaux declares : assemblables, et gradues | — | ✅ OK | 8 panneau(x) declare(s) dans 2 role(s), tous avec titre, expression, raison et une unite que la table sait traduire. |
|
||||||
|
| P78 | Un consommateur de base suit le verrou TLS de son serveur | — | ✅ OK | 3 consommateur(s) suivent la posture de leur serveur ; 2 sans reglage TLS (serveur_icingaweb2, serveur_nextcloud). |
|
||||||
|
| P79 | Replis silencieux : une derivation vide ne passe pas pour un succes | — | ❌ ECHEC | 1 repli(s) silencieux — une derivation vide rend un succes :
|
||||||
|
- flux : OPS-Chezlepro-lab — perimees par rapport au plan : ops-01.nft |
|
||||||
|
| P80 | Remise au client : inscrite, nommee, et son second temps a l'heure | — | ✅ OK | Aucun ecosysteme remis a un client : rien a tenir. |
|
||||||
|
| P81 | La console dit sa portee, et ne sert pas un inventaire vide en silence | — | ✅ OK | Portee `poste` derivee des voutes portees, source d'inventaire `instance/inventories/principal/hosts.yml`, et 15 route(s) POST exigent toutes un pouvoir. |
|
||||||
|
| P82 | DNS public : les zones publiees sont servies, signees avant d'etre exposees | — | ✅ OK | 2 zone(s) publique(s) declaree(s), toutes servies par le site ; 2 replication(s) declaree(s) des deux cotes ; transfert ouvert a la cle seule ; aucune expositio |
|
||||||
|
| P83 | Assistants : le registre des runbooks ne prend pas de retard sur le Makefile | — | ✅ OK | 17 runbooks, 139 etapes, 145 cibles documentees : chacune portee par un assistant ou exemptee avec son motif. |
|
||||||
|
|
||||||
|
## Couverture des affirmations ✅ du registre
|
||||||
|
|
||||||
|
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
|
||||||
|
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
|
||||||
|
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
|
||||||
|
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
|
||||||
|
elles restent hors du harnais recurrent (rien d'executable a rejouer).
|
||||||
|
|
||||||
|
## Declarations d'intention (⚪ invérifiables localement — assumees)
|
||||||
|
|
||||||
|
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
|
||||||
|
comme declarations d'intention, non comme preuves :
|
||||||
|
|
||||||
|
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
|
||||||
|
contre une flotte vivante.
|
||||||
|
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
|
||||||
|
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
|
||||||
|
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
|
||||||
|
|
||||||
|
_Rapport genere le 2026-09-30._
|
||||||
119
docs/audit/preuve-2026-10-01.md
Normal file
119
docs/audit/preuve-2026-10-01.md
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
# Preuve de conformite — Set-OPS — 2026-10-01
|
||||||
|
|
||||||
|
> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer
|
||||||
|
> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;
|
||||||
|
> aucune validation n'est reimplementee ici. Voir le mode d'emploi :
|
||||||
|
> [`docs/audit/README.md`](README.md), et le registre trace :
|
||||||
|
> [`docs/audit/affirmations.md`](affirmations.md).
|
||||||
|
|
||||||
|
- **Instance** : `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance` — inventaire `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance/inventories/principal/hosts.yml`
|
||||||
|
- **Verdict** : ✅ CONFORME (82 OK · 0 echec · 1 saute)
|
||||||
|
|
||||||
|
## Preuves
|
||||||
|
|
||||||
|
| # | Preuve | Affirmations | Statut | Detail |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | } \| to_nice_json }}`. |
|
||||||
|
| P02 | Tests unitaires (inventaire, raser, ecritures du plan, rendu du GUI) | — | ✅ OK | OK |
|
||||||
|
| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 4 instance(s) verifiee(s) — instance-ci-1646753, OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre : plan et inventaire applique coincident. |
|
||||||
|
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
|
||||||
|
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
|
||||||
|
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
|
||||||
|
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check), 1 nom(s) surveille(s) sans reference orpheline. |
|
||||||
|
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 42 groupes classes, aucun cycle, aucune arete en arriere ; playbooks/site.yml a jour. |
|
||||||
|
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 40 rôles, 121 flux, schéma + matrice OK. |
|
||||||
|
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
|
||||||
|
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_resolveur_site |
|
||||||
|
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
|
||||||
|
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
|
||||||
|
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
|
||||||
|
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
|
||||||
|
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ⚪ SAUTE | Voute chiffree sans ANSIBLE_VAULT_PASSWORD_FILE (prerequis AFF-026). |
|
||||||
|
| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. |
|
||||||
|
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 32 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) |
|
||||||
|
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 30 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
|
||||||
|
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) et 7 zone(s) de site : adressage 100% derive du seed index. |
|
||||||
|
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 5 instance(s) federee(s), aucun index en collision. |
|
||||||
|
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). |
|
||||||
|
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 14 reseau(x), aucune collision avec la plage tenant. |
|
||||||
|
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | note : serveur_powerdns declare un port `derive` que le plan du site ne resout pas — aucune regle emise. |
|
||||||
|
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 2 tenant(s), 40 groupe(s), 108 regle(s). |
|
||||||
|
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 13 hote(s) x 6 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). |
|
||||||
|
| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. |
|
||||||
|
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 35 VM placee(s), aucun nom ni VMID en collision. |
|
||||||
|
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 34 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 23, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
|
||||||
|
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 2 zone(s), 12 VNet(s), 12 sous-reseau(x), aucune collision. |
|
||||||
|
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 77 scripts expliques et atteignables, 146 cibles make documentees, 69 roles avec README. |
|
||||||
|
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (142 cle(s) declaree(s) par l'instance). |
|
||||||
|
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 44 revendication(s) de port, aucune collision entre roles co-localises (35 groupes). |
|
||||||
|
| P34 | Chaque document declare son lecteur | — | ✅ OK | 49 document(s) declarent leur lecteur (45 genere(s) exempte(s)). |
|
||||||
|
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 4 application(s) exigeant une base l'ont toutes (3 entree(s) au registre). |
|
||||||
|
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 7 hote(s) de l'ecosysteme et 3 du site detiennent de l'etat, tous porteurs de `client_backup` (8 groupe(s) au catalogue). |
|
||||||
|
| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. |
|
||||||
|
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 42 role(s) serveur/client tous nommes, 42 groupe(s) cite(s) en table existent tous. |
|
||||||
|
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 97 page(s) de wiki toutes atteignables. |
|
||||||
|
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
|
||||||
|
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 73 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
|
||||||
|
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (instance-ci-1646753/production, OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre |
|
||||||
|
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 9 machine(s) du plan retrouvees, 202 regle(s) du site. |
|
||||||
|
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 integration(s) appliquent leur serveur avant leurs clients. |
|
||||||
|
| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). |
|
||||||
|
| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos |
|
||||||
|
| P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). |
|
||||||
|
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 101 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. |
|
||||||
|
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (139 lignes). |
|
||||||
|
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 309 regles `pass`), tous non consignes et tous motives. |
|
||||||
|
| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 |
|
||||||
|
| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. |
|
||||||
|
| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 13 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere : WAN muet, interfa |
|
||||||
|
| P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 9 role(s) applique(s), aucun secret de tenant reclame. |
|
||||||
|
| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 13 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 13. |
|
||||||
|
| P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. |
|
||||||
|
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (83 preuves, 69 roles, 42 groupes). |
|
||||||
|
| P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. |
|
||||||
|
| P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). |
|
||||||
|
| P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `3fe7e3c` (publie le 2026-09-29). |
|
||||||
|
| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 56 champ(s) sur 7 registres ; il couvre tout ce que les plans reels contiennent, et la FORME de chaque champ (scalaire / objet / table) corresp |
|
||||||
|
| P62 | Schema du plan : il decrit tout ce que le MOTEUR accepte | AFF-033 | ✅ OK | Les 4 validateurs n'acceptent aucun champ que le schema ignore (applications:8, bases_donnees:4, domaines_publics:9, serveurs:3 champ(s) lus par validateur). |
|
||||||
|
| P63 | cloud-init nait avec la VM et ne lui survit pas | — | ✅ OK | cloud-init est au gabarit (la premiere seconde), absent du socle (pas de va-et-vient), et retire par le durcissement — avec la garde qui verifie que le reseau s |
|
||||||
|
| P64 | Sondes de supervision : declarees ET deposees | — | ✅ OK | 47 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_journal/journaux, client_metrique/metriques, client_pki/certificat, client_s |
|
||||||
|
| P65 | Depots tiers : demandes au cache, jamais en HTTPS direct | — | ✅ OK | 4 depot(s) tiers relaye(s) par le cache, aucun role ne les vise en https:// ecrit en dur. |
|
||||||
|
| P66 | Clients OIDC : chaque URI vise un nom que le plan expose | — | ✅ OK | 4 client(s) OIDC, toutes leurs URI visent un FQDN que le plan expose (7 exposition(s)). |
|
||||||
|
| P67 | Nom public : le service porte celui du plan, pas celui du role | — | ✅ OK | 14 service(s) expose(s) portent le nom du plan, sur 2 inventaire(s) : instance, SITE. |
|
||||||
|
| P68 | Cle de depot telechargee : mesuree avant d'etre utilisee | — | ✅ OK | 5 role(s) telechargent une cle de depot, tous la mesurent avant de s'en servir. |
|
||||||
|
| P69 | Amorcage d'un tenant : l'adresse designe le site REEL | — | ✅ OK | 2 adresse(s) d'amorcage designent bien une machine du site. |
|
||||||
|
| P70 | Depot de binaires : il tient tout ce que les roles vont chercher | — | ✅ OK | 6 artefact(s) direct(s) tenus par le depot du site. |
|
||||||
|
| P71 | Pool du site : le genome ne nait pas chez un tenant | — | ✅ OK | `site-creer` nomme `--pool-site` ; le pool du genome ne peut plus etre celui d'un tenant. |
|
||||||
|
| P72 | Annuaire : aucun service ne se lie avec le compte du maitre | — | ✅ OK | 5 role(s) consultent l'annuaire, chacun avec SON compte de service ; seul `amorcage_acces` garde celui d'administration, et il provisionne au lieu de consommer. |
|
||||||
|
| P73 | Le locataire designe les services de son site REEL | — | ✅ OK | CONFORME : 10 intrant(s) du locataire concordent avec ce que le site expose (1 non declare(s), donc derive(s) ou non utilise(s)). |
|
||||||
|
| P74 | Gabarit dore : une seule declaration, au plan du site | — | ✅ OK | Gabarit declare une seule fois : VMID 9006 « modeleSetOPS-minimal », precedent 99998. |
|
||||||
|
| P75 | Les parametres de clonage traversent les trois maillons | — | ✅ OK | 14 parametre(s) de clonage, tous emis par l'inventaire. |
|
||||||
|
| P76 | Tout gabarit de role se rend vraiment | — | ✅ OK | 185 gabarits de role : tous se rendent. |
|
||||||
|
| P77 | Panneaux declares : assemblables, et gradues | — | ✅ OK | 8 panneau(x) declare(s) dans 2 role(s), tous avec titre, expression, raison et une unite que la table sait traduire. |
|
||||||
|
| P78 | Un consommateur de base suit le verrou TLS de son serveur | — | ✅ OK | 3 consommateur(s) suivent la posture de leur serveur ; 2 sans reglage TLS (serveur_icingaweb2, serveur_nextcloud). |
|
||||||
|
| P79 | Replis silencieux : une derivation vide ne passe pas pour un succes | — | ✅ OK | 5 ecosysteme(s) (instance-ci-1646753, OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, SITE-Chezlepro) : pattes, edges, certificats, rechargements, jumeaux d' |
|
||||||
|
| P80 | Remise au client : inscrite, nommee, et son second temps a l'heure | — | ✅ OK | Aucun ecosysteme remis a un client : rien a tenir. |
|
||||||
|
| P81 | La console dit sa portee, et ne sert pas un inventaire vide en silence | — | ✅ OK | Portee `poste` derivee des voutes portees, source d'inventaire `instance/inventories/principal/hosts.yml`, et 15 route(s) POST exigent toutes un pouvoir. |
|
||||||
|
| P82 | DNS public : les zones publiees sont servies, signees avant d'etre exposees | — | ✅ OK | 2 zone(s) publique(s) declaree(s), toutes servies par le site ; 2 replication(s) declaree(s) des deux cotes ; transfert ouvert a la cle seule ; aucune expositio |
|
||||||
|
| P83 | Assistants : le registre des runbooks ne prend pas de retard sur le Makefile | — | ✅ OK | 17 runbooks, 139 etapes, 145 cibles documentees : chacune portee par un assistant ou exemptee avec son motif. |
|
||||||
|
|
||||||
|
## Couverture des affirmations ✅ du registre
|
||||||
|
|
||||||
|
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
|
||||||
|
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
|
||||||
|
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
|
||||||
|
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
|
||||||
|
elles restent hors du harnais recurrent (rien d'executable a rejouer).
|
||||||
|
|
||||||
|
## Declarations d'intention (⚪ invérifiables localement — assumees)
|
||||||
|
|
||||||
|
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
|
||||||
|
comme declarations d'intention, non comme preuves :
|
||||||
|
|
||||||
|
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
|
||||||
|
contre une flotte vivante.
|
||||||
|
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
|
||||||
|
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
|
||||||
|
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
|
||||||
|
|
||||||
|
_Rapport genere le 2026-10-01._
|
||||||
120
docs/audit/preuve-2026-10-03.md
Normal file
120
docs/audit/preuve-2026-10-03.md
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
# Preuve de conformite — Set-OPS — 2026-10-03
|
||||||
|
|
||||||
|
> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer
|
||||||
|
> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;
|
||||||
|
> aucune validation n'est reimplementee ici. Voir le mode d'emploi :
|
||||||
|
> [`docs/audit/README.md`](README.md), et le registre trace :
|
||||||
|
> [`docs/audit/affirmations.md`](affirmations.md).
|
||||||
|
|
||||||
|
- **Instance** : `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance` — inventaire `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance/inventories/principal/hosts.yml`
|
||||||
|
- **Verdict** : ❌ NON CONFORME (81 OK · 1 echec · 1 saute)
|
||||||
|
|
||||||
|
## Preuves
|
||||||
|
|
||||||
|
| # | Preuve | Affirmations | Statut | Detail |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | [0m[0m |
|
||||||
|
| P02 | Tests unitaires (inventaire, raser, ecritures du plan, rendu du GUI) | — | ✅ OK | OK |
|
||||||
|
| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 4 instance(s) verifiee(s) — instance-ci-1646753, OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre : plan et inventaire applique coincident. |
|
||||||
|
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
|
||||||
|
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
|
||||||
|
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
|
||||||
|
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check), 1 nom(s) surveille(s) sans reference orpheline. |
|
||||||
|
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 42 groupes classes, aucun cycle, aucune arete en arriere ; playbooks/site.yml a jour. |
|
||||||
|
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 40 rôles, 121 flux, schéma + matrice OK. |
|
||||||
|
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
|
||||||
|
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_resolveur_site |
|
||||||
|
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
|
||||||
|
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
|
||||||
|
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
|
||||||
|
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
|
||||||
|
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ⚪ SAUTE | Voute chiffree sans ANSIBLE_VAULT_PASSWORD_FILE (prerequis AFF-026). |
|
||||||
|
| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. |
|
||||||
|
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 32 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) |
|
||||||
|
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 30 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
|
||||||
|
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) et 7 zone(s) de site : adressage 100% derive du seed index. |
|
||||||
|
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 5 instance(s) federee(s), aucun index en collision. |
|
||||||
|
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). |
|
||||||
|
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 14 reseau(x), aucune collision avec la plage tenant. |
|
||||||
|
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | note : serveur_powerdns declare un port `derive` que le plan du site ne resout pas — aucune regle emise. |
|
||||||
|
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 2 tenant(s), 40 groupe(s), 108 regle(s). |
|
||||||
|
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 13 hote(s) x 6 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). |
|
||||||
|
| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. |
|
||||||
|
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 35 VM placee(s), aucun nom ni VMID en collision. |
|
||||||
|
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 34 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 23, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
|
||||||
|
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 2 zone(s), 12 VNet(s), 12 sous-reseau(x), aucune collision. |
|
||||||
|
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 77 scripts expliques et atteignables, 146 cibles make documentees, 69 roles avec README. |
|
||||||
|
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (142 cle(s) declaree(s) par l'instance). |
|
||||||
|
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 44 revendication(s) de port, aucune collision entre roles co-localises (35 groupes). |
|
||||||
|
| P34 | Chaque document declare son lecteur | — | ✅ OK | 49 document(s) declarent leur lecteur (46 genere(s) exempte(s)). |
|
||||||
|
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 4 application(s) exigeant une base l'ont toutes (3 entree(s) au registre). |
|
||||||
|
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 7 hote(s) de l'ecosysteme et 3 du site detiennent de l'etat, tous porteurs de `client_backup` (8 groupe(s) au catalogue). |
|
||||||
|
| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. |
|
||||||
|
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 42 role(s) serveur/client tous nommes, 42 groupe(s) cite(s) en table existent tous. |
|
||||||
|
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 97 page(s) de wiki toutes atteignables. |
|
||||||
|
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
|
||||||
|
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 73 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
|
||||||
|
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (instance-ci-1646753/production, OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre |
|
||||||
|
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 9 machine(s) du plan retrouvees, 202 regle(s) du site. |
|
||||||
|
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 integration(s) appliquent leur serveur avant leurs clients. |
|
||||||
|
| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). |
|
||||||
|
| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos |
|
||||||
|
| P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). |
|
||||||
|
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ❌ ECHEC | La carte d'orientation ne dit plus vrai :
|
||||||
|
- « pieces d'audit » : la carte annonce 50, le depot en compte 51 |
|
||||||
|
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (139 lignes). |
|
||||||
|
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 309 regles `pass`), tous non consignes et tous motives. |
|
||||||
|
| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 |
|
||||||
|
| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. |
|
||||||
|
| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 13 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere : WAN muet, interfa |
|
||||||
|
| P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 9 role(s) applique(s), aucun secret de tenant reclame. |
|
||||||
|
| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 13 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 13. |
|
||||||
|
| P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. |
|
||||||
|
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (83 preuves, 69 roles, 42 groupes). |
|
||||||
|
| P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. |
|
||||||
|
| P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). |
|
||||||
|
| P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `3fe7e3c` (publie le 2026-09-29). |
|
||||||
|
| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 56 champ(s) sur 7 registres ; il couvre tout ce que les plans reels contiennent, et la FORME de chaque champ (scalaire / objet / table) corresp |
|
||||||
|
| P62 | Schema du plan : il decrit tout ce que le MOTEUR accepte | AFF-033 | ✅ OK | Les 4 validateurs n'acceptent aucun champ que le schema ignore (applications:8, bases_donnees:4, domaines_publics:9, serveurs:3 champ(s) lus par validateur). |
|
||||||
|
| P63 | cloud-init nait avec la VM et ne lui survit pas | — | ✅ OK | cloud-init est au gabarit (la premiere seconde), absent du socle (pas de va-et-vient), et retire par le durcissement — avec la garde qui verifie que le reseau s |
|
||||||
|
| P64 | Sondes de supervision : declarees ET deposees | — | ✅ OK | 47 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_journal/journaux, client_metrique/metriques, client_pki/certificat, client_s |
|
||||||
|
| P65 | Depots tiers : demandes au cache, jamais en HTTPS direct | — | ✅ OK | 4 depot(s) tiers relaye(s) par le cache, aucun role ne les vise en https:// ecrit en dur. |
|
||||||
|
| P66 | Clients OIDC : chaque URI vise un nom que le plan expose | — | ✅ OK | 4 client(s) OIDC, toutes leurs URI visent un FQDN que le plan expose (7 exposition(s)). |
|
||||||
|
| P67 | Nom public : le service porte celui du plan, pas celui du role | — | ✅ OK | 14 service(s) expose(s) portent le nom du plan, sur 2 inventaire(s) : instance, SITE. |
|
||||||
|
| P68 | Cle de depot telechargee : mesuree avant d'etre utilisee | — | ✅ OK | 5 role(s) telechargent une cle de depot, tous la mesurent avant de s'en servir. |
|
||||||
|
| P69 | Amorcage d'un tenant : l'adresse designe le site REEL | — | ✅ OK | 2 adresse(s) d'amorcage designent bien une machine du site. |
|
||||||
|
| P70 | Depot de binaires : il tient tout ce que les roles vont chercher | — | ✅ OK | 6 artefact(s) direct(s) tenus par le depot du site. |
|
||||||
|
| P71 | Pool du site : le genome ne nait pas chez un tenant | — | ✅ OK | `site-creer` nomme `--pool-site` ; le pool du genome ne peut plus etre celui d'un tenant. |
|
||||||
|
| P72 | Annuaire : aucun service ne se lie avec le compte du maitre | — | ✅ OK | 5 role(s) consultent l'annuaire, chacun avec SON compte de service ; seul `amorcage_acces` garde celui d'administration, et il provisionne au lieu de consommer. |
|
||||||
|
| P73 | Le locataire designe les services de son site REEL | — | ✅ OK | CONFORME : 10 intrant(s) du locataire concordent avec ce que le site expose (1 non declare(s), donc derive(s) ou non utilise(s)). |
|
||||||
|
| P74 | Gabarit dore : une seule declaration, au plan du site | — | ✅ OK | Gabarit declare une seule fois : VMID 9006 « modeleSetOPS-minimal », precedent 99998. |
|
||||||
|
| P75 | Les parametres de clonage traversent les trois maillons | — | ✅ OK | 14 parametre(s) de clonage, tous emis par l'inventaire. |
|
||||||
|
| P76 | Tout gabarit de role se rend vraiment | — | ✅ OK | 185 gabarits de role : tous se rendent. |
|
||||||
|
| P77 | Panneaux declares : assemblables, et gradues | — | ✅ OK | 8 panneau(x) declare(s) dans 2 role(s), tous avec titre, expression, raison et une unite que la table sait traduire. |
|
||||||
|
| P78 | Un consommateur de base suit le verrou TLS de son serveur | — | ✅ OK | 3 consommateur(s) suivent la posture de leur serveur ; 2 sans reglage TLS (serveur_icingaweb2, serveur_nextcloud). |
|
||||||
|
| P79 | Replis silencieux : une derivation vide ne passe pas pour un succes | — | ✅ OK | 5 ecosysteme(s) (instance-ci-1646753, OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, SITE-Chezlepro) : pattes, edges, certificats, rechargements, jumeaux d' |
|
||||||
|
| P80 | Remise au client : inscrite, nommee, et son second temps a l'heure | — | ✅ OK | Aucun ecosysteme remis a un client : rien a tenir. |
|
||||||
|
| P81 | La console dit sa portee, et ne sert pas un inventaire vide en silence | — | ✅ OK | Portee `poste` derivee des voutes portees, source d'inventaire `instance/inventories/principal/hosts.yml`, et 15 route(s) POST exigent toutes un pouvoir. |
|
||||||
|
| P82 | DNS public : les zones publiees sont servies, signees avant d'etre exposees | — | ✅ OK | 2 zone(s) publique(s) declaree(s), toutes servies par le site ; 2 replication(s) declaree(s) des deux cotes ; transfert ouvert a la cle seule ; aucune expositio |
|
||||||
|
| P83 | Assistants : le registre des runbooks ne prend pas de retard sur le Makefile | — | ✅ OK | 17 runbooks, 139 etapes, 145 cibles documentees : chacune portee par un assistant ou exemptee avec son motif. |
|
||||||
|
|
||||||
|
## Couverture des affirmations ✅ du registre
|
||||||
|
|
||||||
|
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
|
||||||
|
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
|
||||||
|
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
|
||||||
|
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
|
||||||
|
elles restent hors du harnais recurrent (rien d'executable a rejouer).
|
||||||
|
|
||||||
|
## Declarations d'intention (⚪ invérifiables localement — assumees)
|
||||||
|
|
||||||
|
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
|
||||||
|
comme declarations d'intention, non comme preuves :
|
||||||
|
|
||||||
|
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
|
||||||
|
contre une flotte vivante.
|
||||||
|
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
|
||||||
|
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
|
||||||
|
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
|
||||||
|
|
||||||
|
_Rapport genere le 2026-10-03._
|
||||||
130
docs/audit/preuve-2026-10-09.md
Normal file
130
docs/audit/preuve-2026-10-09.md
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
# Preuve de conformite — Set-OPS — 2026-10-09
|
||||||
|
|
||||||
|
> 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** : `instance` — inventaire `instance/inventories/principal/hosts.yml`
|
||||||
|
- **Verdict** : ✅ CONFORME (94 OK · 0 echec · 0 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 | 3 instance(s) verifiee(s) — 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, 119 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 | ✅ OK | 13 hotes, 33 groupes (inventaire dechiffre et parse). |
|
||||||
|
| 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 : 35 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 : 4 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 | 78 scripts expliques et atteignables, 151 cibles make documentees, 69 roles avec README. |
|
||||||
|
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (148 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 | 50 document(s) declarent leur lecteur (47 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 (4 entree(s) au registre). |
|
||||||
|
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 9 hote(s) de l'ecosysteme et 3 du site detiennent de l'etat, tous porteurs de `client_backup` (11 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 : 74 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
|
||||||
|
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 3 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal). |
|
||||||
|
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 9 machine(s) du plan retrouvees, 201 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 102 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 (137 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 304 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 (94 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 `e9215a2` (publie le 2026-10-07). |
|
||||||
|
| 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 (6 exposition(s)). |
|
||||||
|
| P67 | Nom public : le service porte celui du plan, pas celui du role | — | ✅ OK | 13 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 | 27 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 ». |
|
||||||
|
| 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 | 189 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 | 4 ecosysteme(s) (OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, SITE-Chezlepro) : pattes, edges, certificats, rechargements, jumeaux d'amorcage, sorties du |
|
||||||
|
| 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, 144 etapes, 150 cibles documentees : chacune portee par un assistant ou exemptee avec son motif. |
|
||||||
|
| P84 | La fiche du site dit exactement ce que chaque locataire porte | — | ✅ OK | 2 fiche(s) de site : chacune dit exactement ce que son locataire porte (copies, racine, inventaire genere). |
|
||||||
|
| P85 | La face reseau du locataire dit exactement ce que le site en tire | — | ✅ OK | 2 face(s) reseau : chacune dit exactement ce que le site en tire (decouverte, devis Proxmox, frontiere, DNS public, sauvegarde). |
|
||||||
|
| P86 | Machine par machine, le locataire et Proxmox admettent les memes entrees | — | ✅ OK | 2 locataire(s) : machine par machine et port par port, nftables et Proxmox admettent les memes sources. |
|
||||||
|
| P87 | La frontiere designe chaque locataire par les identites qu'il publie | — | ✅ OK | 2 locataire(s) : la frontiere les designe par les identites qu'ils publient (supernet, administration, tunnel, groupes, routes, sortie). |
|
||||||
|
| P88 | Ce qui entre depuis l'Internet chez un locataire, la frontiere le tient de lui | — | ✅ OK | 2 locataire(s) : regles WAN, redirections et tunnel disent exactement ce que leur face reseau ouvre a tous. |
|
||||||
|
| P89 | Ce que l'administration atteint chez un locataire, la frontiere le tient de lui | — | ✅ OK | 2 locataire(s) : par la gestion, le VPN et le tunnel, l'administration atteint exactement ce que leur face reseau lui ouvre. |
|
||||||
|
| P90 | Ce qui sort d'un locataire vers l'Internet, la frontiere le tient de lui | — | ✅ OK | 2 locataire(s) : la frontiere laisse sortir exactement ce que leur face reseau publie. |
|
||||||
|
| P91 | La fiche du site est deposee a jour, et l'inventaire se genere sans le site | — | ✅ OK | 2 locataire(s) : fiche du site deposee a jour, et inventaire genere sans le site identique a l'octet pres. |
|
||||||
|
| P92 | Chaque locataire a publie sa face reseau a jour | — | ✅ OK | 2 locataire(s) : face reseau publiee, a jour. |
|
||||||
|
| P93 | La face publie, machine par machine, les parametres de clonage de l'inventaire | — | ✅ OK | 26 machine(s) : la face publie exactement les parametres de clonage de l'inventaire. |
|
||||||
|
| P94 | Creer, raser et placer un locataire par sa face visent les memes machines | — | ✅ OK | 2 locataire(s) : creer, raser et placer visent par la face les memes machines que par l'instance montee. |
|
||||||
|
|
||||||
|
## 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-09._
|
||||||
131
docs/audit/preuve-2026-10-11.md
Normal file
131
docs/audit/preuve-2026-10-11.md
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
# Preuve de conformite — Set-OPS — 2026-10-11
|
||||||
|
|
||||||
|
> 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** : `instance` — inventaire `instance/inventories/principal/hosts.yml`
|
||||||
|
- **Verdict** : ✅ CONFORME (95 OK · 0 echec · 0 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 | 3 instance(s) verifiee(s) — 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, 123 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 | ✅ OK | 13 hotes, 33 groupes (inventaire dechiffre et parse). |
|
||||||
|
| 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 : 35 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 : 4 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 | 81 scripts expliques et atteignables, 154 cibles make documentees, 69 roles avec README. |
|
||||||
|
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 38 exigence(s) de role, toutes satisfaites (150 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 | 51 document(s) declarent leur lecteur (48 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 (4 entree(s) au registre). |
|
||||||
|
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 9 hote(s) de l'ecosysteme et 3 du site detiennent de l'etat, tous porteurs de `client_backup` (11 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 : 77 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
|
||||||
|
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 3 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal). |
|
||||||
|
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 9 machine(s) du plan retrouvees, 175 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 103 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 (141 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 278 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 (95 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 `e9215a2` (publie le 2026-10-07). |
|
||||||
|
| 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 (6 exposition(s)). |
|
||||||
|
| P67 | Nom public : le service porte celui du plan, pas celui du role | — | ✅ OK | 13 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 | 27 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 : 11 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 ». |
|
||||||
|
| 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 | 189 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 | 4 ecosysteme(s) (OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, SITE-Chezlepro) : pattes, edges, certificats, rechargements, jumeaux d'amorcage, sorties du |
|
||||||
|
| 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, 148 etapes, 153 cibles documentees : chacune portee par un assistant ou exemptee avec son motif. |
|
||||||
|
| P84 | La fiche du site dit exactement ce que chaque locataire porte | — | ✅ OK | 2 fiche(s) de site : chacune dit exactement ce que son locataire porte (copies, racine, inventaire genere). |
|
||||||
|
| P85 | La face reseau du locataire dit exactement ce que le site en tire | — | ✅ OK | 2 face(s) reseau : chacune dit exactement ce que le site en tire (decouverte, devis Proxmox, frontiere, DNS public, sauvegarde). |
|
||||||
|
| P86 | Machine par machine, le locataire et Proxmox admettent les memes entrees | — | ✅ OK | 2 locataire(s) : machine par machine et port par port, nftables et Proxmox admettent les memes sources. |
|
||||||
|
| P87 | La frontiere designe chaque locataire par les identites qu'il publie | — | ✅ OK | 2 locataire(s) : la frontiere les designe par les identites qu'ils publient (supernet, administration, tunnel, groupes, routes, sortie). |
|
||||||
|
| P88 | Ce qui entre depuis l'Internet chez un locataire, la frontiere le tient de lui | — | ✅ OK | 2 locataire(s) : regles WAN, redirections et tunnel disent exactement ce que leur face reseau ouvre a tous. |
|
||||||
|
| P89 | Ce que l'administration atteint chez un locataire, la frontiere le tient de lui | — | ✅ OK | 2 locataire(s) : par la gestion, le VPN et le tunnel, l'administration atteint exactement ce que leur face reseau lui ouvre. |
|
||||||
|
| P90 | Ce qui sort d'un locataire vers l'Internet, la frontiere le tient de lui | — | ✅ OK | 2 locataire(s) : la frontiere laisse sortir exactement ce que leur face reseau publie. |
|
||||||
|
| P91 | La fiche du site est deposee a jour, et l'inventaire se genere sans le site | — | ✅ OK | 2 locataire(s) : fiche du site deposee a jour, et inventaire genere sans le site identique a l'octet pres. |
|
||||||
|
| P92 | Chaque locataire a publie sa face reseau a jour | — | ✅ OK | 2 locataire(s) : face reseau publiee, a jour. |
|
||||||
|
| P93 | La face publie, machine par machine, les parametres de clonage de l'inventaire | — | ✅ OK | 26 machine(s) : la face publie exactement les parametres de clonage de l'inventaire. |
|
||||||
|
| P94 | Creer, raser et placer un locataire par sa face visent les memes machines | — | ✅ OK | 2 locataire(s) : creer, raser et placer visent par la face les memes machines que par l'instance montee. |
|
||||||
|
| P95 | Chaque locataire publie la cle qui lira son depot du genome, et sa flotte la connait | — | ✅ OK | 2 locataire(s) : depot et cle de lecture publies, cle connue de leur flotte. |
|
||||||
|
|
||||||
|
## 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-11._
|
||||||
|
|
@ -288,6 +288,15 @@
|
||||||
"description": "Le GROUPE Ansible qui sert cette zone (ex. serveur_nginx).",
|
"description": "Le GROUPE Ansible qui sert cette zone (ex. serveur_nginx).",
|
||||||
"x-source-valeurs": "groupes_edge"
|
"x-source-valeurs": "groupes_edge"
|
||||||
},
|
},
|
||||||
|
"resolution_interne": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Resolution interne",
|
||||||
|
"description": "`service` : le plancher et la zone menent au service lui-meme, l'edge ne sert qu'aux personnes.",
|
||||||
|
"enum": [
|
||||||
|
"edge",
|
||||||
|
"service"
|
||||||
|
]
|
||||||
|
},
|
||||||
"secondaires": {
|
"secondaires": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"title": "Secondaires",
|
"title": "Secondaires",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
# Ecrit par `make wiki-publier`, lu par la preuve P60. Ne pas editer a la main.
|
# Ecrit par `make wiki-publier`, lu par la preuve P60. Ne pas editer a la main.
|
||||||
remote: ssh://git@eregion.chezlepro.ca:2222/Alliance-Boreale/Set-OPS-Public.wiki.git
|
remote: ssh://git@eregion.chezlepro.ca:2222/Alliance-Boreale/Set-OPS-Public.wiki.git
|
||||||
source: 2bbe122
|
source: e9215a2
|
||||||
date: 2026-09-29
|
date: 2026-10-07
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,8 @@ README de rôles). Cette page comble ces deux trous.
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| rôles | 69 | `roles/*/` |
|
| rôles | 69 | `roles/*/` |
|
||||||
| README de rôles | 69 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
|
| README de rôles | 69 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
|
||||||
| documents | 45 | `docs/*.md` |
|
| documents | 47 | `docs/*.md` |
|
||||||
| pièces d'audit | 48 | `docs/audit/*` |
|
| pièces d'audit | 53 | `docs/audit/*` |
|
||||||
| unités de wiki | 27 | `wiki/*.md` |
|
| unités de wiki | 27 | `wiki/*.md` |
|
||||||
| décisions en vigueur | 85 | lignes `\| **D-nn** \|` de `decisions-architecture.md` |
|
| décisions en vigueur | 85 | lignes `\| **D-nn** \|` de `decisions-architecture.md` |
|
||||||
| décisions renversées | 3 | lignes `\| **D-nn** —` du même document |
|
| décisions renversées | 3 | lignes `\| **D-nn** —` du même document |
|
||||||
|
|
@ -36,7 +36,7 @@ README de rôles). Cette page comble ces deux trous.
|
||||||
| Sujet | Documents |
|
| Sujet | Documents |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Autorité / gouvernance** | `AGENTS.md` (source d'autorité), `CLAUDE.md`, `docs/MISE-A-JOUR-CODEX-CLAUDE.md` |
|
| **Autorité / gouvernance** | `AGENTS.md` (source d'autorité), `CLAUDE.md`, `docs/MISE-A-JOUR-CODEX-CLAUDE.md` |
|
||||||
| **Le modèle (plan)** | `docs/architecture-set-ops.md` (survol) → `docs/plan-et-generation.md` (à fond) → `docs/meta-classe.md` (concept) |
|
| **Le modèle (plan)** | `docs/architecture-set-ops.md` (survol) → `docs/plan-et-generation.md` (à fond) → `docs/meta-classe.md` (concept) → `docs/conception-contextes.md` (site et locataire : deux classes, un contrat) → `docs/depots-prives-conception.md` (chaque runner ne lit que ses dépôts ; chantier) |
|
||||||
| **Services, maturité, dette** | `docs/catalogue-services.md` (**la carte de maturité + la cruft y sont déjà**) |
|
| **Services, maturité, dette** | `docs/catalogue-services.md` (**la carte de maturité + la cruft y sont déjà**) |
|
||||||
| **Exploitation / VM** | `docs/vm-lifecycle.md`, `docs/procedure-template-debian13-proxmox.md`, `docs/config-proxmox.md`, `docs/nomenclature-vm.md`, `docs/multi-instances.md` |
|
| **Exploitation / VM** | `docs/vm-lifecycle.md`, `docs/procedure-template-debian13-proxmox.md`, `docs/config-proxmox.md`, `docs/nomenclature-vm.md`, `docs/multi-instances.md` |
|
||||||
| **Conceptions de domaine** | `docs/identite-sso.md`, `docs/courriel-conception.md`, `docs/bindings-conception.md`, `docs/dns-interne.md`, `docs/dimensionnement-ressources.md`, `docs/integrations-vm.md` |
|
| **Conceptions de domaine** | `docs/identite-sso.md`, `docs/courriel-conception.md`, `docs/bindings-conception.md`, `docs/dns-interne.md`, `docs/dimensionnement-ressources.md`, `docs/integrations-vm.md` |
|
||||||
|
|
|
||||||
279
docs/conception-contextes.md
Normal file
279
docs/conception-contextes.md
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
# 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), sauf la clé du runner du site. Le site l'ajoute lui-même en
|
||||||
|
clonant le runner du locataire (`clonage_rendu`, 2026-10-11) : elle ne transite plus par la
|
||||||
|
face, et un locataire peut calculer sa face sans le plan de son site ; `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.
|
||||||
149
docs/depots-prives-conception.md
Normal file
149
docs/depots-prives-conception.md
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
# Dépôts privés sur la forge du site — conception du chantier
|
||||||
|
|
||||||
|
> **Pour qui :** le mainteneur qui décide de ce chantier puis le conduit, et quiconque se demande
|
||||||
|
> pourquoi un runner ne lit que ses propres dépôts.
|
||||||
|
|
||||||
|
Préparé le 2026-10-11. Décisions D1 à D3 prises le même jour par Daniel : les trois recommandations.
|
||||||
|
|
||||||
|
## 1. Le constat
|
||||||
|
|
||||||
|
Sur la forge du site, l'organisation `genome` porte cinq dépôts, tous **publics** :
|
||||||
|
|
||||||
|
| Dépôt | Contenu | Qui en a besoin |
|
||||||
|
|---|---|---|
|
||||||
|
| `set-ops-public` | le moteur | tous les runners |
|
||||||
|
| `set-ops-modeles` | les modèles génériques | le runner du site |
|
||||||
|
| `site-chezlepro` | la carte de la fabric, le plan du site | le runner du site |
|
||||||
|
| `ops-chezlepro` | le plan, l'inventaire et la face de Chezlepro | son runner, le runner du site |
|
||||||
|
| `ops-technolibre` | le plan, l'inventaire et la face de Technolibre | son runner, le runner du site |
|
||||||
|
|
||||||
|
Mesuré le 2026-10-11 : depuis le runner de Technolibre, `git ls-remote` sans identifiant lit la
|
||||||
|
tête de `ops-chezlepro` (`b4fbd85`) et de `site-chezlepro` (`51dae24`). Chaque locataire peut donc
|
||||||
|
lire le plan de l'autre, et la carte du site. **Aucun n'en a besoin** : chaque runner de locataire
|
||||||
|
ne clone que le moteur et son propre dépôt ; ce que le site lui donne arrive par la fiche déposée
|
||||||
|
dans son dépôt.
|
||||||
|
|
||||||
|
D'où ça vient : le 2026-09-12, à la première reconstruction du site, les dépôts créés privés ont
|
||||||
|
cassé le clonage anonyme des runners. Ils sont passés publics, avec l'argument que seuls ceux
|
||||||
|
qui ont franchi la frontière joignent la forge (`scripts/forge_amorcer.py`). Les autres locataires
|
||||||
|
en font partie.
|
||||||
|
|
||||||
|
## 2. La cible
|
||||||
|
|
||||||
|
- **Publics** : `set-ops-public` et `set-ops-modeles`. Ils sont génériques, identiques pour tous.
|
||||||
|
- **Privés** : `site-chezlepro` et chaque `ops-<locataire>`.
|
||||||
|
- **Le runner du site** lit et pousse tous les dépôts avec sa clé, déjà enregistrée comme clé de
|
||||||
|
déploiement en écriture par `genome_pousser.yml`. Mesuré le 2026-10-11 : il lit les cinq dépôts
|
||||||
|
en SSH, avec une vérification stricte de la clé d'hôte. Rien à créer pour lui.
|
||||||
|
- **Le runner d'un locataire** lit son seul dépôt, avec une clé de déploiement **en lecture
|
||||||
|
seule** : la clé publique de son runner, déjà déclarée au plan (`ssh_baseline_cles_admin`) et
|
||||||
|
dont la privée lui est remise à l'armement depuis sa voûte (`vault_runner_cle_ssh`).
|
||||||
|
- **Aucun secret ne change de main.** Le locataire publie la clé publique de son runner dans sa
|
||||||
|
face réseau ; le site l'enregistre sur le dépôt de ce locataire, avec ses propres
|
||||||
|
identifiants d'administration de la forge (`vault_forgejo_admin`, déjà dans sa voûte).
|
||||||
|
|
||||||
|
### Éprouvé le 2026-10-11 (étape 1)
|
||||||
|
|
||||||
|
Clés de lecture enregistrées par `make forge-cles`, flux posé sur `site-forge-01` et à la
|
||||||
|
frontière. Depuis chaque runner, `git ls-remote` en SSH, clé d'hôte de la forge épinglée (lue
|
||||||
|
sur sa machine par l'accès d'administration) :
|
||||||
|
|
||||||
|
| Depuis | son dépôt | l'autre locataire | `site-chezlepro` | `set-ops-public` |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| runner de Technolibre | lu (`0172dd8`) | refusé | refusé | refusé |
|
||||||
|
| runner de Chezlepro | lu (`74d5567`) | refusé | refusé | refusé |
|
||||||
|
|
||||||
|
Le refus vient de la forge (« Deploy Key … is not authorized to read »), **alors que les dépôts
|
||||||
|
sont encore publics** : une clé de déploiement ne vaut que pour le dépôt qui l'a enregistrée.
|
||||||
|
Conséquence : le moteur, public, continue de se cloner en HTTPS sans identifiant ; seuls les
|
||||||
|
dépôts privés passent par SSH.
|
||||||
|
|
||||||
|
## 3. Le nœud : l'insémination
|
||||||
|
|
||||||
|
Le site amorce le runner d'un locataire **avant** que celui-ci ait reçu sa clé stable (elle
|
||||||
|
arrive à l'armement, geste humain, après). Pendant l'insémination, le rôle `serveur_ops` clone
|
||||||
|
pourtant le dépôt du locataire : avec un dépôt privé, ce clonage échoue. Même chose au premier
|
||||||
|
déploiement du runner du site, dont la clé est neuve.
|
||||||
|
|
||||||
|
**Remède proposé : le colis.** Quand un runner ne peut pas encore lire un dépôt privé, le
|
||||||
|
contrôleur qui joue `serveur_ops` (le runner du site pendant l'insémination, le poste
|
||||||
|
ailleurs) lui apporte ce dépôt en `git bundle`. Le mécanisme existe déjà : `genome_colis.py` porte
|
||||||
|
le génome du poste au runner du site. Le runner clone le colis, puis pointe `origin` vers l'URL
|
||||||
|
SSH de la forge. Ses tirages suivants passent par sa clé, une fois armé.
|
||||||
|
|
||||||
|
## 4. Les décisions à prendre
|
||||||
|
|
||||||
|
**D1 — Comment le runner d'un locataire lit-il son dépôt ?**
|
||||||
|
- *Clé de déploiement en SSH (recommandé).* Le runner continue de tirer lui-même ; il garde son
|
||||||
|
autonomie (émancipation, `make` lancé sur le runner, garde `verifier_genome_a_jour`). Coût : un
|
||||||
|
flux nouveau, du runner de chaque locataire vers la forge du site, en SSH.
|
||||||
|
- *Colis seulement.* Aucun flux nouveau, aucune clé de déploiement pour les locataires : le
|
||||||
|
contrôleur apporte toujours le dépôt. Mais le runner ne tire plus son dépôt lui-même, et sa
|
||||||
|
garde de génome n'a plus de forge à laquelle se comparer.
|
||||||
|
|
||||||
|
**D2 — Sur quelle écoute SSH ?**
|
||||||
|
- *Le `sshd` de la machine, port 22 (recommandé pour commencer).* Le runner du site et le poste
|
||||||
|
l'utilisent déjà pour pousser ; l'utilisateur `git` y est contraint par la forge. Le flux
|
||||||
|
n'ouvre que les adresses des runners de locataires, pas leurs supernets.
|
||||||
|
- *Le serveur SSH intégré de Forgejo, sur un port dédié.* Il n'expose pas le `sshd` de la
|
||||||
|
machine, mais sa cohabitation avec le `sshd` existant reste à éprouver avant de s'y fier.
|
||||||
|
|
||||||
|
**D3 — La preuve.** Ce chantier touche l'insémination : je propose de le prouver par une
|
||||||
|
reconstruction complète de Technolibre (raser, créer, inséminer, armer, monter), dépôts
|
||||||
|
privés, avant de reprendre le lot 3b.
|
||||||
|
|
||||||
|
## 5. Ce qui change dans le code
|
||||||
|
|
||||||
|
État au 2026-10-11 : tout ce tableau est écrit, sauf la bascule elle-même. La clé d'hôte de
|
||||||
|
la forge est déclarée dans le plan du site (`forge_ssh_cle_hote`), offerte par la fiche
|
||||||
|
(`serveur_ops_forge_cle_hote`) et copiée par chaque locataire. Le moteur reste cloné en HTTPS.
|
||||||
|
|
||||||
|
| Pièce | Changement |
|
||||||
|
|---|---|
|
||||||
|
| `serveur_ops_depots` | un drapeau `prive: true` sur les dépôts `instance`, `tenant` et `hebergeur` |
|
||||||
|
| `roles/serveur_ops` | URL SSH et clé du runner pour les dépôts privés ; colis du contrôleur quand le runner ne peut pas encore les lire ; clé d'hôte de la forge épinglée |
|
||||||
|
| `contexte.py` (face) | la face publie la clé publique de lecture du runner (`genome.cle_lecture`) |
|
||||||
|
| `contexte.py` (fiche) | la fiche du site publie la clé d'hôte SSH de sa forge, que le runner du locataire épingle |
|
||||||
|
| nouvelle cible `forge-cles` | le site enregistre, d'après les faces, la clé de lecture de chaque locataire sur son dépôt, et retire celles qui ne sont plus publiées |
|
||||||
|
| `forge_amorcer.py` | crée les dépôts privés comme privés |
|
||||||
|
| flux | `serveur_forge_site` : entrée SSH depuis les runners des locataires hébergés (paire nouvelle, résolue depuis les faces) ; `serveur_ops` : sortie correspondante |
|
||||||
|
| `genome_etat.yml` | lit la forge par SSH ou authentifié : l'API anonyme répondrait 404 pour un dépôt privé |
|
||||||
|
| `sonde-genome-servi` | ne compte plus que les dépôts publics (au moins le moteur) : c'est ce qu'un écosystème neuf clone sans rien détenir ; les privés sont suivis par `genome-etat`, `forge-cles` et les runners |
|
||||||
|
|
||||||
|
Inchangés : `sonde-genome-a-jour` (son repère ne compare que `set-ops-public`), le poste (il
|
||||||
|
pousse déjà en SSH), la console (`etat_forge` lit `origin`, qui deviendra l'URL SSH).
|
||||||
|
|
||||||
|
## 6. Les preuves à écrire
|
||||||
|
|
||||||
|
- **Hors ligne (`make prouver`)** : chaque dépôt `instance`, `tenant` ou `hebergeur` est déclaré
|
||||||
|
privé ; chaque face publie une clé de lecture, et cette clé est dans `ssh_baseline_cles_admin`.
|
||||||
|
- **Sur le terrain (`make forge-etancheite`)**, avec témoins :
|
||||||
|
- sans identifiant, un dépôt privé refuse et un dépôt public répond ;
|
||||||
|
- le runner d'un locataire lit son dépôt et **ne lit pas** celui de l'autre ;
|
||||||
|
- les clés de déploiement de chaque dépôt sont exactement : runner du site en écriture, runner
|
||||||
|
du locataire en lecture.
|
||||||
|
- **Par reconstruction** : D3.
|
||||||
|
|
||||||
|
## 7. L'ordre de bascule
|
||||||
|
|
||||||
|
**Fait le 2026-10-11, étapes 1 à 5.** Bascule par `make forge-cles CONFIRMER=true`, mesurée
|
||||||
|
ETANCHE par `make forge-etancheite`. Preuve par reconstruction de Technolibre (§D3) : le runner
|
||||||
|
neuf a reçu son dépôt en colis à l'insémination (il n'avait pas encore de clé), l'a tiré en SSH
|
||||||
|
une fois armé, et la flotte est remontée avec ses témoins d'état intacts. La reconstruction a
|
||||||
|
d'abord révélé un dossier SSH manquant sur une machine neuve (corrigé, `3fc16c8`).
|
||||||
|
|
||||||
|
Chaque étape se vérifie avant la suivante, et la bascule se défait d'un appel par dépôt
|
||||||
|
(repasser public).
|
||||||
|
|
||||||
|
1. **Éprouver** sur la forge du site, sans rien basculer : enregistrer la clé de lecture du
|
||||||
|
runner de Technolibre sur `ops-technolibre`, ouvrir le flux pour ce seul runner, et mesurer
|
||||||
|
qu'il lit son dépôt en SSH.
|
||||||
|
2. **Construire** le code et les preuves (§5, §6) ; `make verifier`.
|
||||||
|
3. **Déployer sans basculer** : flux et frontière (`make frontiere-appliquer CONFIRMER=true`,
|
||||||
|
lancé par Daniel), clés de déploiement, remotes SSH sur les trois runners. Les dépôts sont
|
||||||
|
encore publics : chaque runner doit déjà tirer en SSH.
|
||||||
|
4. **Basculer** `site-chezlepro`, `ops-chezlepro` et `ops-technolibre` en privé. Mesurer avec
|
||||||
|
`make forge-etancheite`, puis la veille des trois consoles.
|
||||||
|
5. **Prouver** par la reconstruction de Technolibre (D3).
|
||||||
|
6. Reprendre le lot 3b : les demandes vivent alors dans le dépôt de chaque locataire, lisibles
|
||||||
|
par lui et par le site seulement.
|
||||||
|
|
@ -30,7 +30,7 @@ make placement-plan # chaque VM est-elle là où le plan la met
|
||||||
|
|
||||||
## Le trou qu'il comble
|
## Le trou qu'il comble
|
||||||
|
|
||||||
`scripts/prouver.py` porte 83 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le
|
`scripts/prouver.py` porte 95 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le
|
||||||
dépôt. Zéro appel réseau, zéro SSH, zéro `ansible`. Elles établissent que le dépôt est
|
dépôt. Zéro appel réseau, zéro SSH, zéro `ansible`. Elles établissent que le dépôt est
|
||||||
cohérent **avec lui-même** — que les handlers existent, que les intrants ont un
|
cohérent **avec lui-même** — que les handlers existent, que les intrants ont un
|
||||||
propriétaire, que rien n'est codé en dur.
|
propriétaire, que rien n'est codé en dur.
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,31 @@ Un **résolveur** (miroir de `instancier`) agrège, par serveur, les `flux.yml`
|
||||||
- **le registre d'audit** : `docs/registre-flux.md` (généré) + une cible `make flux`.
|
- **le registre d'audit** : `docs/registre-flux.md` (généré) + une cible `make flux`.
|
||||||
|
|
||||||
## Activation prudente
|
## Activation prudente
|
||||||
|
|
||||||
|
### Descriptions conservées par les pare-feux
|
||||||
|
|
||||||
|
La `raison` non vide du flux et son rôle alimentent les champs natifs : `comment`
|
||||||
|
pour nftables et Proxmox, `description` pour le filtrage OPNsense (`descr` pour ses
|
||||||
|
redirections). Un commentaire de fichier `# ...` ne survit pas au chargement nftables.
|
||||||
|
Les règles communes (administration, connexions établies, refus) portent aussi leur motif.
|
||||||
|
|
||||||
|
Les libellés sont sur une ligne. Le commentaire nftables est borné à 127 octets UTF-8 ;
|
||||||
|
OPNsense garde sa clé `setops:...` complète et borne le texte qui suit pour rester dans
|
||||||
|
255 octets. Une raison abrégée reste disponible en entier dans le registre des flux.
|
||||||
|
Le commentaire Proxmox conserve aussi le pair nommé dans le devis.
|
||||||
|
|
||||||
|
Une correction de description apparaît dans `make proxmox-fw-plan` et
|
||||||
|
`make frontiere-plan`. Son application met à jour le champ sur la règle existante,
|
||||||
|
puis le relit : elle ne recrée pas la règle et ne modifie ni son activation ni sa
|
||||||
|
politique. Les confirmations habituelles restent requises pour appliquer ces devis.
|
||||||
|
`make test` vérifie notamment cette convergence et les limites des champs.
|
||||||
|
|
||||||
|
Références des champs natifs : [nftables](https://netfilter.org/projects/nftables/manpage.html),
|
||||||
|
[API Proxmox](https://github.com/proxmox/pve-firewall/blob/master/src/PVE/API2/Firewall/Rules.pm),
|
||||||
|
[descriptions OPNsense](https://github.com/opnsense/core/blob/master/src/opnsense/mvc/app/models/OPNsense/Base/FieldTypes/DescriptionField.php).
|
||||||
|
|
||||||
|
### Activer le filtrage
|
||||||
|
|
||||||
Activer nftables = **action destructive** (peut couper l'accès) → confirmation explicite +
|
Activer nftables = **action destructive** (peut couper l'accès) → confirmation explicite +
|
||||||
déploiement graduel (garder l'accès SSH/Ansible, tester par nœud).
|
déploiement graduel (garder l'accès SSH/Ansible, tester par nœud).
|
||||||
|
|
||||||
|
|
@ -130,4 +155,3 @@ ignorée, ce qui est pire que de ne pas l'avoir.
|
||||||
**Corollaire à retenir** : un port qu'on **subit** (le défaut amont d'un logiciel) doit être
|
**Corollaire à retenir** : un port qu'on **subit** (le défaut amont d'un logiciel) doit être
|
||||||
imposé et déclaré comme les autres. Celui d'Alloy ne l'était pas, et c'est la seule raison
|
imposé et déclaré comme les autres. Celui d'Alloy ne l'était pas, et c'est la seule raison
|
||||||
pour laquelle la collision a pu durer des semaines.
|
pour laquelle la collision a pu durer des semaines.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -430,3 +430,12 @@ Reste à trancher sur une interface **physique** : `ip ?`, `access-group ?`, `sp
|
||||||
déclare 53 en UDP et TCP, la récursion depuis la racine que le choix souverain implique.
|
déclare 53 en UDP et TCP, la récursion depuis la racine que le choix souverain implique.
|
||||||
Le `block out` de la section 5 est donc un vrai default-deny **assumé**, et le devis
|
Le `block out` de la section 5 est donc un vrai default-deny **assumé**, et le devis
|
||||||
l'énonce désormais au lieu de le poser en silence.
|
l'énonce désormais au lieu de le poser en silence.
|
||||||
|
- **La sortie générale n'existe plus — 2026-10-10.** Le 123 était déjà passé à la frontière
|
||||||
|
(2026-09-11). Le 443 et le 80 du socle sont devenus conditionnels (`seulement_si:
|
||||||
|
artefacts_amorcage` vide) : le site et ses locataires prennent apt par le cache du site,
|
||||||
|
et ne les reçoivent plus. Mesurée à la frontière, cette sortie ne servait plus qu'à ce
|
||||||
|
qu'aucun rôle ne déclarait : télémétrie de Grafana, d'Alloy et de Loki et vérification
|
||||||
|
de version de Collabora (coupées à la source), cartes de rspamd et repère du génome
|
||||||
|
(déclarés dans `serveur_rspamd` et `serveur_ops_site`). Chaque sortie vers Internet porte
|
||||||
|
désormais le nom du rôle qui en a besoin. `test_conditions_site.py` refuse qu'un rôle
|
||||||
|
universel en rouvre une sans condition.
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
| `serveur_backup_site` | ingress | 22 | tcp | voisins_site | ssh | Les écosystèmes de ce site déposent leur état ici tant qu'ils n'ont pas leur propre dépôt. SFTP par un utilisateur restreint, jamais un compte d'administration : le site reçoit des octets chiffrés par restic, il ne peut pas les lire. |
|
| `serveur_backup_site` | ingress | 22 | tcp | voisins_site | ssh | Les écosystèmes de ce site déposent leur état ici tant qu'ils n'ont pas leur propre dépôt. SFTP par un utilisateur restreint, jamais un compte d'administration : le site reçoit des octets chiffrés par restic, il ne peut pas les lire. |
|
||||||
| `serveur_cache_site` | ingress | 3142 | tcp | voisins_site | clair | Servir les caches des écosystèmes voisins. Debian n'est ainsi téléchargé qu'une fois pour tout le site, et le cache ne voit que des requêtes AGRÉGÉES — jamais quelle machine installe quoi. |
|
| `serveur_cache_site` | ingress | 3142 | tcp | voisins_site | clair | Servir les caches des écosystèmes voisins. Debian n'est ainsi téléchargé qu'une fois pour tout le site, et le cache ne voit que des requêtes AGRÉGÉES — jamais quelle machine installe quoi. |
|
||||||
| `serveur_cache_site` | egress | 80 | tcp | externe | clair | Remplir le cache depuis les dépôts Debian amont. En clair parce que les dépôts apt sont signés : l'intégrité vient de la signature, pas du transport. |
|
| `serveur_cache_site` | egress | 80 | tcp | externe | clair | Remplir le cache depuis les dépôts Debian amont. En clair parce que les dépôts apt sont signés : l'intégrité vient de la signature, pas du transport. |
|
||||||
| `serveur_cache_site` | egress | 443 | tcp | externe | tls-requis | Les dépôts tiers qui n'existent qu'en HTTPS (smallstep, Grafana, Icinga). Le cache les relaie pour que la flotte n'ait pas à sortir elle-même. |
|
| `serveur_cache_site` | egress | 443 | tcp | externe | tls-requis | Les dépôts tiers qui n'existent qu'en HTTPS (smallstep, Grafana, Icinga), et le dépôt de binaires (roues PyPI, collections Galaxy, versions GitHub, Nextcloud, Forgejo). Le site les relaie pour que la flotte n'ait pas à sortir elle-même. |
|
||||||
| `serveur_collabora` | ingress | 9980 | tcp | edge | clair | Éditeur servi au navigateur via l'edge (WebSocket WOPI ; TLS terminé à l'edge). |
|
| `serveur_collabora` | ingress | 9980 | tcp | edge | clair | Éditeur servi au navigateur via l'edge (WebSocket WOPI ; TLS terminé à l'edge). |
|
||||||
| `serveur_collabora` | ingress | 9980 | tcp | localhost | clair | Vérifications WOPI serveur→Collabora depuis Nextcloud co-localisé. |
|
| `serveur_collabora` | ingress | 9980 | tcp | localhost | clair | Vérifications WOPI serveur→Collabora depuis Nextcloud co-localisé. |
|
||||||
| `serveur_debian` | ingress | 22 | tcp | flotte, externe | ssh | Plan de gestion : administration et déploiement Ansible par SSH (inter-nœud ; l'accès depuis l'extérieur est filtré à l'OPNsense). |
|
| `serveur_debian` | ingress | 22 | tcp | flotte, externe | ssh | Plan de gestion : administration et déploiement Ansible par SSH (inter-nœud ; l'accès depuis l'extérieur est filtré à l'OPNsense). |
|
||||||
|
|
@ -31,9 +31,9 @@
|
||||||
| `serveur_debian` | ingress | frag-needed | icmp | externe | n-a | ICMP « fragmentation nécessaire » entrant : sans lui, un distant ne peut pas nous demander de réduire nos paquets — les transferts se figent. |
|
| `serveur_debian` | ingress | frag-needed | icmp | externe | n-a | ICMP « fragmentation nécessaire » entrant : sans lui, un distant ne peut pas nous demander de réduire nos paquets — les transferts se figent. |
|
||||||
| `serveur_debian` | egress | 53 | udp | frontiere | n-a | Résolution de noms à l'amorçage, avant que le résolveur de l'écosystème n'existe. Sans elle, les machines d'un site neuf ne peuvent pas résoudre leurs dépôts de paquets — et rien ne peut donc s'installer, y compris le résolveur lui-même. |
|
| `serveur_debian` | egress | 53 | udp | frontiere | n-a | Résolution de noms à l'amorçage, avant que le résolveur de l'écosystème n'existe. Sans elle, les machines d'un site neuf ne peuvent pas résoudre leurs dépôts de paquets — et rien ne peut donc s'installer, y compris le résolveur lui-même. |
|
||||||
| `serveur_debian` | egress | 53 | tcp | frontiere | n-a | Réponses longues et bascule TCP, obligatoires en DNS. Déclarer l'UDP sans le TCP donne une résolution qui marche jusqu'à la première réponse tronquée. |
|
| `serveur_debian` | egress | 53 | tcp | frontiere | n-a | Réponses longues et bascule TCP, obligatoires en DNS. Déclarer l'UDP sans le TCP donne une résolution qui marche jusqu'à la première réponse tronquée. |
|
||||||
| `serveur_debian` | egress | 80 | tcp | externe | clair | Dépôts apt en clair et redirections HTTP des miroirs (l'intégrité vient de la signature des paquets, pas du transport). |
|
| `serveur_debian` | egress | 80 | tcp | externe | clair | Dépôts apt en clair et redirections des miroirs, pour un écosystème SANS amont d'artefacts. L'intégrité vient de la signature des paquets, pas du transport. |
|
||||||
| `serveur_debian` | egress | 123 | udp | frontiere | n-a | Synchronisation d'horloge (NTP) contre la frontière, autorité de temps de l'écosystème. Une dérive fait échouer la validation des certificats step-ca et l'authentification SSO. |
|
| `serveur_debian` | egress | 123 | udp | frontiere | n-a | Synchronisation d'horloge (NTP) contre la frontière, autorité de temps de l'écosystème. Une dérive fait échouer la validation des certificats step-ca et l'authentification SSO. |
|
||||||
| `serveur_debian` | egress | 443 | tcp | externe | tls-requis | Dépôts apt en HTTPS (Debian, Smallstep, Grafana, Icinga, Forgejo, Nextcloud) — sans quoi aucun correctif de sécurité n'entre. |
|
| `serveur_debian` | egress | 443 | tcp | externe | tls-requis | Dépôts apt en direct, pour un écosystème SANS aucune source d'artefacts. Le site et ses locataires passent par le cache du site et ne la reçoivent pas. |
|
||||||
| `serveur_debian` | egress | frag-needed | icmp | externe | n-a | ICMP « fragmentation nécessaire » sortant : c'est ainsi que nos hôtes signalent l'overlay à 1450 aux correspondants distants. |
|
| `serveur_debian` | egress | frag-needed | icmp | externe | n-a | ICMP « fragmentation nécessaire » sortant : c'est ainsi que nos hôtes signalent l'overlay à 1450 aux correspondants distants. |
|
||||||
| `serveur_dns_public` | ingress | 53 | udp | voisins_site | clair | NOTIFY des primaires des locataires du site : une zone publique a change. Le contenu d'une zone publique n'a rien de secret ; ce qui compte est QUI peut le modifier, et c'est TSIG qui le garde, sur le transfert. |
|
| `serveur_dns_public` | ingress | 53 | udp | voisins_site | clair | NOTIFY des primaires des locataires du site : une zone publique a change. Le contenu d'une zone publique n'a rien de secret ; ce qui compte est QUI peut le modifier, et c'est TSIG qui le garde, sur le transfert. |
|
||||||
| `serveur_dns_public` | ingress | 53 | tcp | voisins_site | clair | Repli TCP des notifications des primaires des locataires. |
|
| `serveur_dns_public` | ingress | 53 | tcp | voisins_site | clair | Repli TCP des notifications des primaires des locataires. |
|
||||||
|
|
@ -45,6 +45,7 @@
|
||||||
| `serveur_dovecot` | ingress | 993 | tcp | externe | tls-requis | Accès courriel des utilisateurs (IMAPS). Frontière publique gérée à l'OPNsense. |
|
| `serveur_dovecot` | ingress | 993 | tcp | externe | tls-requis | Accès courriel des utilisateurs (IMAPS). Frontière publique gérée à l'OPNsense. |
|
||||||
| `serveur_dovecot` | ingress | 12345 | tcp | serveur_postfix | tls | Authentification SASL déléguée : Postfix valide les identifiants de soumission contre Dovecot. |
|
| `serveur_dovecot` | ingress | 12345 | tcp | serveur_postfix | tls | Authentification SASL déléguée : Postfix valide les identifiants de soumission contre Dovecot. |
|
||||||
| `serveur_dovecot` | egress | 636 | tcp | serveur_openldap | tls-requis | userdb/passdb : Dovecot résout et authentifie les comptes sur l'annuaire (LDAPS). |
|
| `serveur_dovecot` | egress | 636 | tcp | serveur_openldap | tls-requis | userdb/passdb : Dovecot résout et authentifie les comptes sur l'annuaire (LDAPS). |
|
||||||
|
| `serveur_forge_site` | ingress | 22 | tcp | runners_locataires | ssh | Chaque runner de locataire lit son propre depot du genome, prive, avec sa cle de deploiement en lecture seule. Il ne lit ni le depot d'un autre locataire, ni celui du site. |
|
||||||
| `serveur_forge_site` | ingress | 443 | tcp | voisins_site | tls-requis | Servir le génome aux écosystèmes de ce site : c'est de cette forge qu'ils clonent leur moteur, leurs modèles et la carte de la fabric (D-81). Sans ce flux, un écosystème neuf ne peut pas se reproduire. |
|
| `serveur_forge_site` | ingress | 443 | tcp | voisins_site | tls-requis | Servir le génome aux écosystèmes de ce site : c'est de cette forge qu'ils clonent leur moteur, leurs modèles et la carte de la fabric (D-81). Sans ce flux, un écosystème neuf ne peut pas se reproduire. |
|
||||||
| `serveur_forgejo` | ingress | 3000 | tcp | edge | clair | Interface web + Git HTTP derrière un edge : le nginx termine le TLS et parle en clair à la forge. C'est le cas de tout tenant. |
|
| `serveur_forgejo` | ingress | 3000 | tcp | edge | clair | Interface web + Git HTTP derrière un edge : le nginx termine le TLS et parle en clair à la forge. C'est le cas de tout tenant. |
|
||||||
| `serveur_forgejo` | ingress | derive | tcp | flotte, admin | tls | Sans edge devant elle — la forge du SITE — elle sert son propre TLS sur le port du schéma (443), avec le certificat de la machine. `derive` parce que le port vient de `serveur_forgejo_http_port` : écrire 3000 en dur ici serait faux pour elle, et rien ne le signalerait puisque ce flux ne traverse pas la frontière. |
|
| `serveur_forgejo` | ingress | derive | tcp | flotte, admin | tls | Sans edge devant elle — la forge du SITE — elle sert son propre TLS sur le port du schéma (443), avec le certificat de la machine. `derive` parce que le port vient de `serveur_forgejo_http_port` : écrire 3000 en dur ici serait faux pour elle, et rien ne le signalerait puisque ce flux ne traverse pas la frontière. |
|
||||||
|
|
@ -74,8 +75,7 @@
|
||||||
| `serveur_nextcloud` | egress | 443 | tcp | edge | tls-requis | Découverte OIDC auprès de Keycloak (via son FQDN publié à l'edge). |
|
| `serveur_nextcloud` | egress | 443 | tcp | edge | tls-requis | Découverte OIDC auprès de Keycloak (via son FQDN publié à l'edge). |
|
||||||
| `serveur_nextcloud` | egress | 5432 | tcp | serveur_postgresql | tls-requis | Base de données Nextcloud (verify-full). |
|
| `serveur_nextcloud` | egress | 5432 | tcp | serveur_postgresql | tls-requis | Base de données Nextcloud (verify-full). |
|
||||||
| `serveur_nextcloud` | egress | 9980 | tcp | serveur_collabora | tls-cible | Vérifications WOPI serveur->Collabora (édition en ligne). TLS interne = feuille de route edge->backends. |
|
| `serveur_nextcloud` | egress | 9980 | tcp | serveur_collabora | tls-cible | Vérifications WOPI serveur->Collabora (édition en ligne). TLS interne = feuille de route edge->backends. |
|
||||||
| `serveur_nginx` | ingress | 80 | tcp | externe | clair | HTTP entrant — redirection permanente vers HTTPS. |
|
| `serveur_nginx` | ingress | 80 | tcp | admin | clair | HTTP depuis le reseau d'administration — redirection permanente vers HTTPS. |
|
||||||
| `serveur_nginx` | ingress | 443 | tcp | externe | tls-requis | HTTPS entrant — services exposés (terminaison TLS à l'edge). |
|
|
||||||
| `serveur_nginx` | ingress | 443 | tcp | flotte | tls-requis | HTTPS depuis le tenant : les FQDN publiés vivent à l'edge (découverte OIDC, appels inter-services par nom). |
|
| `serveur_nginx` | ingress | 443 | tcp | flotte | tls-requis | HTTPS depuis le tenant : les FQDN publiés vivent à l'edge (découverte OIDC, appels inter-services par nom). |
|
||||||
| `serveur_nginx` | ingress | 443 | tcp | admin | tls-requis | HTTPS depuis le reseau d'administration : l'exploitant administre les services par leur interface web, servie par l'edge. |
|
| `serveur_nginx` | ingress | 443 | tcp | admin | tls-requis | HTTPS depuis le reseau d'administration : l'exploitant administre les services par leur interface web, servie par l'edge. |
|
||||||
| `serveur_nginx` | egress | derive | tcp | expositions | tls-cible | Proxy vers les backends exposés (host:port dérivés des expose ; TLS interne = roadmap edge→backends). |
|
| `serveur_nginx` | egress | derive | tcp | expositions | tls-cible | Proxy vers les backends exposés (host:port dérivés des expose ; TLS interne = roadmap edge→backends). |
|
||||||
|
|
@ -87,15 +87,15 @@
|
||||||
| `serveur_ops` | ingress | 8090 | tcp | edge, admin | clair | Console d'exploitation servie par l'edge (TLS terminé à l'edge), et joignable depuis le plan d'administration là où il n'y a pas d'edge. Le GUI lui-même reste sur la boucle locale : c'est nginx qui authentifie devant. |
|
| `serveur_ops` | ingress | 8090 | tcp | edge, admin | clair | Console d'exploitation servie par l'edge (TLS terminé à l'edge), et joignable depuis le plan d'administration là où il n'y a pas d'edge. Le GUI lui-même reste sur la boucle locale : c'est nginx qui authentifie devant. |
|
||||||
| `serveur_ops` | egress | 22 | tcp | flotte | ssh | Piloter la flotte — c'est la raison d'être du poste. |
|
| `serveur_ops` | egress | 22 | tcp | flotte | ssh | Piloter la flotte — c'est la raison d'être du poste. |
|
||||||
| `serveur_ops` | egress | 443 | tcp | edge | tls-requis | Cloner et resynchroniser le génome depuis la forge de l'écosystème. |
|
| `serveur_ops` | egress | 443 | tcp | edge | tls-requis | Cloner et resynchroniser le génome depuis la forge de l'écosystème. |
|
||||||
| `serveur_ops` | egress | 443 | tcp | voisins_site | tls-requis | Cloner le genome depuis la forge du site, quand cet ecosysteme n'heberge pas la sienne. |
|
|
||||||
| `serveur_ops` | egress | 8006 | tcp | externe | tls-requis | API de l'hyperviseur : créer et cloner les VM d'un écosystème descendant. |
|
|
||||||
| `serveur_ops_site` | egress | 22 | tcp | fabric | ssh | Shell des hyperviseurs : ce que l'API ne couvre pas — configuration reseau, ponts, deplacement de disques. Un pouvoir distinct de l'API, donc declare a part. |
|
| `serveur_ops_site` | egress | 22 | tcp | fabric | ssh | Shell des hyperviseurs : ce que l'API ne couvre pas — configuration reseau, ponts, deplacement de disques. Un pouvoir distinct de l'API, donc declare a part. |
|
||||||
| `serveur_ops_site` | egress | 22 | tcp | serveur_ops_tenant | ssh | Insemination : amorcer le runner d'un tenant — socle, moteur, plan, plancher de resolution — pour qu'il prenne ensuite le relais sur ses propres machines. Ne transporte aucun secret : la voute est remise par un humain. |
|
| `serveur_ops_site` | egress | 22 | tcp | serveur_ops_tenant | ssh | Insemination : amorcer le runner d'un tenant — socle, moteur, plan, plancher de resolution — pour qu'il prenne ensuite le relais sur ses propres machines. Ne transporte aucun secret : la voute est remise par un humain. |
|
||||||
| `serveur_ops_site` | egress | 443 | tcp | fabric | tls-requis | API de la frontiere OPNsense : poser les alias et les regles qui ouvrent les flux du tenant qu'on materialise. Preparer le terrain sans cela laisserait un terrain injoignable. |
|
| `serveur_ops_site` | egress | 443 | tcp | fabric | tls-requis | API de la frontiere OPNsense : poser les alias et les regles qui ouvrent les flux du tenant qu'on materialise. Preparer le terrain sans cela laisserait un terrain injoignable. |
|
||||||
|
| `serveur_ops_site` | egress | 443 | tcp | externe | tls-requis | Repere du genome : `git ls-remote` anonyme sur eregion (forge.alliance-boreale.ca) pour dire si la forge du site a pris du retard. Lecture seule, sans identifiant. |
|
||||||
| `serveur_ops_site` | egress | 8006 | tcp | fabric | tls-requis | API de l'hyperviseur : créer, cloner et détruire les VM de la fabric. Le seul flux par lequel un écosystème peut en matérialiser un autre. |
|
| `serveur_ops_site` | egress | 8006 | tcp | fabric | tls-requis | API de l'hyperviseur : créer, cloner et détruire les VM de la fabric. Le seul flux par lequel un écosystème peut en matérialiser un autre. |
|
||||||
| `serveur_ops_tenant` | ingress | 22 | tcp | runner_site | ssh | Insémination : le runner du SITE amorce ce runner-ci — socle, moteur, plan, plancher de résolution — jusqu'à ce qu'un humain lui remette sa voûte. Vers cette machine seule, jamais vers le reste de l'écosystème. |
|
| `serveur_ops_tenant` | ingress | 22 | tcp | runner_site | ssh | Insémination : le runner du SITE amorce ce runner-ci — socle, moteur, plan, plancher de résolution — jusqu'à ce qu'un humain lui remette sa voûte. Vers cette machine seule, jamais vers le reste de l'écosystème. |
|
||||||
| `serveur_postfix` | ingress | 25 | tcp | externe, client_smtp | starttls | SMTP entrant : courrier externe (MX) et notifications internes (client_smtp). |
|
| `serveur_postfix` | ingress | 25 | tcp | externe, client_smtp | starttls | SMTP entrant : courrier externe (MX) et notifications internes (client_smtp). |
|
||||||
| `serveur_postfix` | ingress | 587 | tcp | flotte | starttls | Soumission authentifiée (submission) pour les agents internes qui envoient du courrier. |
|
| `serveur_postfix` | ingress | 465 | tcp | flotte, externe | tls-requis | Soumission authentifiée en TLS direct (submissions, RFC 8314) : clients de courriel des utilisateurs. |
|
||||||
|
| `serveur_postfix` | ingress | 587 | tcp | flotte, externe | starttls | Soumission authentifiée (STARTTLS obligatoire) : agents internes et clients de courriel des utilisateurs. |
|
||||||
| `serveur_postfix` | egress | 24 | tcp | serveur_dovecot | tls-requis | Remise finale par LMTP au mailstore (Dovecot), en TLS vérifié. |
|
| `serveur_postfix` | egress | 24 | tcp | serveur_dovecot | tls-requis | Remise finale par LMTP au mailstore (Dovecot), en TLS vérifié. |
|
||||||
| `serveur_postfix` | egress | 25 | tcp | externe | starttls | Relais sortant vers les MX distants (STARTTLS opportuniste). |
|
| `serveur_postfix` | egress | 25 | tcp | externe | starttls | Relais sortant vers les MX distants (STARTTLS opportuniste). |
|
||||||
| `serveur_postfix` | egress | 636 | tcp | serveur_openldap | tls-requis | Tables virtuelles (domaines/alias/boîtes) résolues sur l'annuaire (LDAPS). |
|
| `serveur_postfix` | egress | 636 | tcp | serveur_openldap | tls-requis | Tables virtuelles (domaines/alias/boîtes) résolues sur l'annuaire (LDAPS). |
|
||||||
|
|
@ -121,18 +121,21 @@
|
||||||
| `serveur_resolveur_site` | ingress | 53 | tcp | voisins_site | clair | Réponses longues et bascule TCP, obligatoires en DNS. |
|
| `serveur_resolveur_site` | ingress | 53 | tcp | voisins_site | clair | Réponses longues et bascule TCP, obligatoires en DNS. |
|
||||||
| `serveur_rspamd` | ingress | 11332 | tcp | localhost | clair | Protocole milter consommé par Postfix co-localisé (analyse + signature DKIM). Local uniquement. |
|
| `serveur_rspamd` | ingress | 11332 | tcp | localhost | clair | Protocole milter consommé par Postfix co-localisé (analyse + signature DKIM). Local uniquement. |
|
||||||
| `serveur_rspamd` | ingress | 11334 | tcp | localhost | clair | Interface de contrôle rspamd (statistiques, apprentissage) en local. |
|
| `serveur_rspamd` | ingress | 11334 | tcp | localhost | clair | Interface de contrôle rspamd (statistiques, apprentissage) en local. |
|
||||||
|
| `serveur_rspamd` | egress | 80 | tcp | externe | clair | Carte de hachages SURBL (sa-update.surbl.org), publiee en clair par l'amont. Une carte alteree ne peut que mal classer un courriel ; elle n'execute rien. |
|
||||||
|
| `serveur_rspamd` | egress | 443 | tcp | externe | tls-requis | Cartes de rspamd (maps.rspamd.com) : messageries gratuites et jetables, redirecteurs, listes blanches DMARC/SPF/DKIM, hameconnage. Sans elles l'antispam garde les copies du paquet, qui vieillissent. |
|
||||||
| `serveur_step_ca` | ingress | 8443 | tcp | flotte | tls-requis | ACME + API step-ca : chaque nœud (client_pki) émet/renouvelle ses certificats et récupère la racine. |
|
| `serveur_step_ca` | ingress | 8443 | tcp | flotte | tls-requis | ACME + API step-ca : chaque nœud (client_pki) émet/renouvelle ses certificats et récupère la racine. |
|
||||||
| `serveur_web_dorsal` | ingress | 80 | tcp | edge | clair | Front nginx local des webapps, proxie par l'edge (TLS termine a l'edge). |
|
| `serveur_web_dorsal` | ingress | 80 | tcp | edge, serveur_web_frontal | clair | Front nginx local des webapps et des sites statiques, relaye par l'edge (noms internes) et par le web frontal (noms publics). |
|
||||||
| `serveur_web_dorsal` | egress | 443 | tcp | flotte | tls | git clone/pull du depot de chaque app (Forgejo souverain) au deploiement. |
|
| `serveur_web_dorsal` | egress | 443 | tcp | flotte | tls | git clone/pull du depot de chaque app (Forgejo souverain) au deploiement. |
|
||||||
| `serveur_web_frontal` | ingress | 80 | tcp | edge | clair | Contenu statique servi au navigateur via l'edge (TLS termine a l'edge). |
|
| `serveur_web_frontal` | ingress | 80 | tcp | externe | clair | HTTP public, relaye a travers le WAF — et le defi HTTP d'ACME ; renverra vers HTTPS quand le certificat sera la. |
|
||||||
| `serveur_web_frontal` | egress | 443 | tcp | flotte | tls | git clone/pull du depot du site (Forgejo souverain) au deploiement. |
|
| `serveur_web_frontal` | ingress | 443 | tcp | externe | tls-requis | HTTPS public — les sites et services que le locataire publie sur l'Internet (attend son certificat Let's Encrypt). |
|
||||||
|
| `serveur_web_frontal` | egress | 80 | tcp | serveur_web_dorsal | clair | Relais vers le web dorsal, qui porte les sites et les applications publiques. |
|
||||||
|
|
||||||
## Synthèse chiffrement
|
## Synthèse chiffrement
|
||||||
|
|
||||||
- **clair** : 50 flux
|
- **clair** : 52 flux
|
||||||
- **n-a** : 8 flux
|
- **n-a** : 8 flux
|
||||||
- **ssh** : 8 flux
|
- **ssh** : 9 flux
|
||||||
- **starttls** : 6 flux
|
- **starttls** : 6 flux
|
||||||
- **tls** : 11 flux
|
- **tls** : 10 flux
|
||||||
- **tls-cible** : 2 flux
|
- **tls-cible** : 2 flux
|
||||||
- **tls-requis** : 35 flux
|
- **tls-requis** : 36 flux
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ Nommer un point ouvert vaut mieux qu'une ligne rassurante sans mécanisme derri
|
||||||
Aucune de ces responsabilités n'est laissée à la parole :
|
Aucune de ces responsabilités n'est laissée à la parole :
|
||||||
|
|
||||||
```
|
```
|
||||||
make prouver le dépôt est-il cohérent avec lui-même (83 preuves, zéro réseau)
|
make prouver le dépôt est-il cohérent avec lui-même (95 preuves, zéro réseau)
|
||||||
make remise-verifier le second temps de la remise est-il fait, ou en retard ? (P80)
|
make remise-verifier le second temps de la remise est-il fait, ou en retard ? (P80)
|
||||||
make certificats-plan ce que le disque porte contre ce que la mémoire sert
|
make certificats-plan ce que le disque porte contre ce que la mémoire sert
|
||||||
make expositions-plan chaque service publié répond-il, et depuis où
|
make expositions-plan chaque service publié répond-il, et depuis où
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,20 @@ variables:
|
||||||
DANS:
|
DANS:
|
||||||
invite: "Jours avant le second temps"
|
invite: "Jours avant le second temps"
|
||||||
facultatif: true
|
facultatif: true
|
||||||
|
JEU:
|
||||||
|
invite: "Le jeu d'etat (openldap, postgresql, nextcloud... — voir restauration-etat)"
|
||||||
|
DEPUIS:
|
||||||
|
invite: "Reprendre a cette etape (vide = depuis le debut)"
|
||||||
|
valeurs: [sauvegarder, raser, creer, inseminer, armer, monter, parefeu, bilan]
|
||||||
|
facultatif: true
|
||||||
|
ARMER:
|
||||||
|
invite: "Armer sans marquer la pause"
|
||||||
|
valeurs: [oui]
|
||||||
|
facultatif: true
|
||||||
|
SOURCE:
|
||||||
|
invite: "Comparer a quel instantane (vide = celui d'avant rasage)"
|
||||||
|
valeurs: [candidat]
|
||||||
|
facultatif: true
|
||||||
VMID:
|
VMID:
|
||||||
invite: "Identifiant Proxmox de la VM"
|
invite: "Identifiant Proxmox de la VM"
|
||||||
DIALECTE:
|
DIALECTE:
|
||||||
|
|
@ -199,6 +213,32 @@ runbooks:
|
||||||
- cible: site-intrants
|
- cible: site-intrants
|
||||||
nature: mesure
|
nature: mesure
|
||||||
pourquoi: "Ce que ce site expose a ses locataires, derive et non declare deux fois."
|
pourquoi: "Ce que ce site expose a ses locataires, derive et non declare deux fois."
|
||||||
|
- cible: fiches-site-deposer
|
||||||
|
nature: ecriture
|
||||||
|
pourquoi: >-
|
||||||
|
Deposer chez chaque locataire la fiche que le site lui destine. Son runner n'a pas
|
||||||
|
le depot du site : c'est par elle que son inventaire se genere sans lui. A commiter
|
||||||
|
dans le depot de chaque locataire.
|
||||||
|
- cible: forge-cles
|
||||||
|
nature: ecriture
|
||||||
|
fixes: {CONFIRMER: "true"}
|
||||||
|
pourquoi: >-
|
||||||
|
Enregistrer sur le depot de chaque locataire la cle de lecture que sa face publie, et
|
||||||
|
retirer celle qui ne l'est plus. Sans elle, son runner ne lit plus son depot prive.
|
||||||
|
- cible: forge-comptes
|
||||||
|
nature: ecriture
|
||||||
|
fixes: {CONFIRMER: "true"}
|
||||||
|
pourquoi: >-
|
||||||
|
Un compte par mainteneur declare, ouvert sur les seuls depots de ses locataires :
|
||||||
|
c'est par un ticket qu'il demande au site. Et le lecteur de la console du site, qui
|
||||||
|
met ces demandes dans « A traiter ».
|
||||||
|
- cible: forge-etancheite
|
||||||
|
nature: mesure
|
||||||
|
portee: poste
|
||||||
|
pourquoi: >-
|
||||||
|
Sans identifiant, chaque depot prive exige une authentification et le moteur
|
||||||
|
repond ; chaque runner de locataire lit son depot et pas celui d'un autre. Du
|
||||||
|
poste, qui voit le site et les runners.
|
||||||
- cible: site-verifier
|
- cible: site-verifier
|
||||||
nature: mesure
|
nature: mesure
|
||||||
pourquoi: "Le playbook du site correspond-il encore aux couches declarees ?"
|
pourquoi: "Le playbook du site correspond-il encore aux couches declarees ?"
|
||||||
|
|
@ -208,6 +248,34 @@ runbooks:
|
||||||
- cible: routes-fabric-etat
|
- cible: routes-fabric-etat
|
||||||
nature: mesure
|
nature: mesure
|
||||||
pourquoi: "Une route de zone manquante est invisible jusqu'au premier invite qui la traverse."
|
pourquoi: "Une route de zone manquante est invisible jusqu'au premier invite qui la traverse."
|
||||||
|
- cible: locataire-creer
|
||||||
|
nature: ecriture
|
||||||
|
variables: [TENANT, PARALLELE]
|
||||||
|
fixes: {CONFIRMER: "true"}
|
||||||
|
pourquoi: >-
|
||||||
|
Cloner les VM d'un locataire NOMME, d'apres la face reseau qu'il publie : le
|
||||||
|
runner du site materialise sans monter son depot. Memes machines, memes
|
||||||
|
parametres que `flotte-creer` sur l'instance montee (P93, P94).
|
||||||
|
# UNE SEULE MACHINE, A LA DEMANDE D'UN MAINTENEUR (2026-10-11). `locataire-creer`
|
||||||
|
# clone TOUTES les VM manquantes ; une demande de locataire en vise une. Les
|
||||||
|
# parametres viennent de la meme face reseau (`clonage`), par le meme chemin
|
||||||
|
# (`PARAMS_CLONAGE`). Sans `TENANT`, `creer-vm` exige une instance montee — que le
|
||||||
|
# site n'a pas : l'oubli se refuse, il ne cree rien de travers.
|
||||||
|
- cible: creer-vm
|
||||||
|
nature: ecriture
|
||||||
|
variables: [TENANT, HOTE]
|
||||||
|
duree: "~4 min 30"
|
||||||
|
pourquoi: >-
|
||||||
|
Cloner UNE machine d'un locataire nomme, d'apres sa face reseau publiee. Elle
|
||||||
|
n'existe pour le site que si son mainteneur l'a mise au plan, a applique
|
||||||
|
l'inventaire, puis a republie sa face.
|
||||||
|
- cible: locataire-raser
|
||||||
|
nature: destructif
|
||||||
|
variables: [TENANT, INSTANCE]
|
||||||
|
fixes: {CONFIRMER: "true"}
|
||||||
|
pourquoi: >-
|
||||||
|
Detruire les VM d'un locataire NOMME, d'apres sa face : les memes VMID que le
|
||||||
|
plan derive (P94). Son nom court s'ecrit en toutes lettres, comme pour `raser`.
|
||||||
- cible: site-raser
|
- cible: site-raser
|
||||||
nature: destructif
|
nature: destructif
|
||||||
variables: [SITE]
|
variables: [SITE]
|
||||||
|
|
@ -310,6 +378,12 @@ runbooks:
|
||||||
pourquoi: >-
|
pourquoi: >-
|
||||||
Regenerer le registre des flux et les regles nftables depuis les `meta/flux.yml`
|
Regenerer le registre des flux et les regles nftables depuis les `meta/flux.yml`
|
||||||
des roles. Tout ce qui suit en descend.
|
des roles. Tout ce qui suit en descend.
|
||||||
|
- cible: face-reseau-publier
|
||||||
|
nature: ecriture
|
||||||
|
pourquoi: >-
|
||||||
|
Publier ce que ce locataire demande a son site (`face-reseau.yml`) : ses machines,
|
||||||
|
ses zones, ses flux deja resolus. Le site ne lit plus que ce fichier. Apres `flux`,
|
||||||
|
et a commiter dans le depot du locataire.
|
||||||
- cible: devis-proxmox-fw
|
- cible: devis-proxmox-fw
|
||||||
nature: mesure
|
nature: mesure
|
||||||
pourquoi: "Le pare-feu est-ouest intra-locataire, derive du registre des flux."
|
pourquoi: "Le pare-feu est-ouest intra-locataire, derive du registre des flux."
|
||||||
|
|
@ -380,6 +454,14 @@ runbooks:
|
||||||
pourquoi: >-
|
pourquoi: >-
|
||||||
Les VM manquantes, clonees depuis le gabarit dore. VMID, IP et VLAN sont DERIVES
|
Les VM manquantes, clonees depuis le gabarit dore. VMID, IP et VLAN sont DERIVES
|
||||||
du plan : on ne les saisit nulle part.
|
du plan : on ne les saisit nulle part.
|
||||||
|
- cible: monter-flotte
|
||||||
|
nature: ecriture
|
||||||
|
duree: "long"
|
||||||
|
fixes: {CONFIRMER: "true"}
|
||||||
|
pourquoi: >-
|
||||||
|
Des VM qui viennent de naitre (ou de renaitre) a la flotte recettee : flux, AC et
|
||||||
|
DNS d'abord, tout le reste, puis `valider`. C'est la sequence du runner apres une
|
||||||
|
reconstruction ; chaque proprietaire d'etat y remet celui d'avant.
|
||||||
- cible: deployer-tout
|
- cible: deployer-tout
|
||||||
nature: ecriture
|
nature: ecriture
|
||||||
duree: "long"
|
duree: "long"
|
||||||
|
|
@ -458,6 +540,24 @@ runbooks:
|
||||||
La preuve la plus dure du moteur : detruire un ecosysteme et le refaire depuis le
|
La preuve la plus dure du moteur : detruire un ecosysteme et le refaire depuis le
|
||||||
code seul. A ne lancer que sur un chantier — la prod vit ailleurs.
|
code seul. A ne lancer que sur un chantier — la prod vit ailleurs.
|
||||||
etapes:
|
etapes:
|
||||||
|
- cible: reconstruire-locataire
|
||||||
|
nature: destructif
|
||||||
|
portee: poste
|
||||||
|
duree: "long"
|
||||||
|
variables: [TENANT, DEPUIS, ARMER]
|
||||||
|
fixes: {CONFIRMER: "true"}
|
||||||
|
pourquoi: >-
|
||||||
|
TOUT, d'une commande, depuis le poste : sauvegarder, raser et recreer (runner du
|
||||||
|
site), amorcer, armer, monter (runner du locataire, etat remis), pare-feu, bilan.
|
||||||
|
Arret a la premiere etape en echec ; `DEPUIS` reprend. Les etapes ci-dessous sont
|
||||||
|
les memes, une a une.
|
||||||
|
- cible: sauvegarder-maintenant
|
||||||
|
nature: ecriture
|
||||||
|
pourquoi: >-
|
||||||
|
Deposer l'etat de chaque noeud JUSTE AVANT de raser. La reconstruction remet le
|
||||||
|
dernier instantane anterieur a la naissance des machines : sans ce depot, c'est
|
||||||
|
celui de la nuit, et la journee est perdue. Il est etiquete « avant-raser » et
|
||||||
|
garde jusqu'a la reconstruction suivante : c'est contre lui que jugent les temoins.
|
||||||
- cible: raser
|
- cible: raser
|
||||||
nature: destructif
|
nature: destructif
|
||||||
portee: poste
|
portee: poste
|
||||||
|
|
@ -472,11 +572,47 @@ runbooks:
|
||||||
duree: "long"
|
duree: "long"
|
||||||
fixes: {CONFIRMER: "true"}
|
fixes: {CONFIRMER: "true"}
|
||||||
pourquoi: >-
|
pourquoi: >-
|
||||||
Refaire tout depuis zero : les VM, puis le deploiement complet. Si le code ne
|
Refaire tout depuis zero : les VM, puis le deploiement complet — ou chaque role
|
||||||
suffit pas, c'est ici qu'on l'apprend.
|
proprietaire REMET l'etat de l'incarnation precedente (AC, annuaire, bases,
|
||||||
|
Nextcloud, courriel, DKIM). Si le code ne suffit pas, c'est ici qu'on l'apprend.
|
||||||
|
- cible: restauration-etat
|
||||||
|
nature: mesure
|
||||||
|
pourquoi: >-
|
||||||
|
Ce que chaque jeu est devenu : restaure (et depuis quel instantane), neuf, en
|
||||||
|
place. Un jeu EN ATTENTE bloque la sauvegarde de son noeud — c'est voulu.
|
||||||
|
- cible: temoins-etat
|
||||||
|
nature: mesure
|
||||||
|
variables: [HOTE, SOURCE]
|
||||||
|
pourquoi: >-
|
||||||
|
L'etat vivant est-il celui d'avant le rasage ? Une perte, ou une identite changee
|
||||||
|
(cles de l'AC, DKIM, instanceid de Nextcloud, mot de passe d'une entree de
|
||||||
|
l'annuaire), est un ecart. Le bilan dit ce que les roles ont fait ; les temoins
|
||||||
|
mesurent ce qui est revenu.
|
||||||
|
- cible: restauration-renoncer
|
||||||
|
nature: destructif
|
||||||
|
variables: [HOTE, JEU]
|
||||||
|
fixes: {CONFIRMER: "true"}
|
||||||
|
pourquoi: >-
|
||||||
|
Ecarter l'etat d'avant d'un jeu, sans le remettre. La sauvegarde reprend, et
|
||||||
|
l'ancien etat sortira de la retention : une decision, pas une reparation.
|
||||||
- cible: valider
|
- cible: valider
|
||||||
nature: mesure
|
nature: mesure
|
||||||
pourquoi: "Une reconstruction sans recette n'a rien prouve."
|
pourquoi: "Une reconstruction sans recette n'a rien prouve."
|
||||||
|
- cible: parefeu-verifier-flotte
|
||||||
|
nature: mesure
|
||||||
|
portee: poste
|
||||||
|
variables: [INSTANCE]
|
||||||
|
pourquoi: >-
|
||||||
|
La sonde `connectivite` est-elle saine sur chaque VM ? C'est le prealable : une VM
|
||||||
|
clonee nait sans ses options de pare-feu, et on n'active que sur une flotte saine.
|
||||||
|
- cible: parefeu-activer-flotte
|
||||||
|
nature: ecriture
|
||||||
|
portee: poste
|
||||||
|
variables: [INSTANCE]
|
||||||
|
fixes: {CONFIRMER: "true"}
|
||||||
|
pourquoi: >-
|
||||||
|
Tout activer d'un coup, puis lire la sonde `connectivite` que chaque VM rapporte a
|
||||||
|
la minute : seul ce qui change apres l'activation compte. Environ trois minutes.
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
- id: gabarit
|
- id: gabarit
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,64 @@ Note : ne s'applique qu'aux Forgejo **gérées par Set-OPS**.
|
||||||
|
|
||||||
## 5. Restaurer — et d'abord : prouver qu'on peut
|
## 5. Restaurer — et d'abord : prouver qu'on peut
|
||||||
|
|
||||||
|
### 5.0 La reconstruction remet l'état (depuis le 2026-09-30)
|
||||||
|
|
||||||
|
Jusqu'au 2026-09-30, **une reconstruction repartait d'un état neuf** : nouvelle racine
|
||||||
|
d'AC, annuaire vierge (`sysadmin` au mot de passe d'amorçage), bases, Nextcloud et courriel
|
||||||
|
vides. Les instantanés se restauraient pour *prouver* qu'ils s'ouvrent, jamais pour être
|
||||||
|
remis en service.
|
||||||
|
|
||||||
|
Désormais **chaque rôle propriétaire remet l'état de l'incarnation précédente**, au moment
|
||||||
|
où il le créerait neuf — la bonne séquence vient des couches de déploiement :
|
||||||
|
|
||||||
|
| Rôle | Quand | Condition « vierge » |
|
||||||
|
|---|---|---|
|
||||||
|
| `serveur_step_ca` | avant `step ca init` | pas de `ca.json` — et la voûte doit ouvrir les clés restaurées |
|
||||||
|
| `serveur_postgresql` | juste après la création des bases | base sans table (hors `nextcloud`, `icinga`) |
|
||||||
|
| `serveur_openldap` | en fin de rôle (schéma et `ppolicy` chargés) | aucune entrée hors la racine |
|
||||||
|
| `serveur_dovecot` | après le répertoire des boîtes | répertoire vide |
|
||||||
|
| `serveur_rspamd` | avant la génération DKIM | pas de clé DKIM |
|
||||||
|
| `serveur_forgejo` | avant l'arborescence de données | répertoire vide |
|
||||||
|
| `serveur_nextcloud` | **en fin de rôle** : base + fichiers + config ensemble, puis `occ upgrade` | pas de `config.php` au départ, base vide |
|
||||||
|
| `serveur_web_dorsal` | après sa racine | racine vide |
|
||||||
|
|
||||||
|
**L'instantané remis** est le dernier pris **avant la naissance de la machine** (date de
|
||||||
|
sa clé d'hôte SSH). Un état en place n'est jamais écrasé d'office. Chaque jeu laisse un
|
||||||
|
marqueur dans `/etc/setops/restauration/<jeu>` ; ce qui a été remplacé est mis de côté dans
|
||||||
|
`/var/backups/setops-avant-restauration/`.
|
||||||
|
|
||||||
|
**La sauvegarde refuse de déposer** (code 3) tant qu'un état d'avant n'a été ni remis ni
|
||||||
|
écarté : sinon `restic forget --keep-daily` chasserait l'instantané d'avant au profit de
|
||||||
|
l'état neuf du même jour.
|
||||||
|
|
||||||
|
**L'instantané d'avant rasage est étiqueté** `avant-raser` (depuis le 2026-10-07) et la
|
||||||
|
rétention le garde **jusqu'à la reconstruction suivante**, qui lui retire l'étiquette. Sans
|
||||||
|
elle, le premier dépôt de la machine reconstruite le chassait le jour même : après la
|
||||||
|
reconstruction de Technolibre (M4), il ne restait rien à quoi comparer l'état remis.
|
||||||
|
|
||||||
|
**Les témoins** comparent l'état vivant à cet instantané. Un écart, c'est une **perte**
|
||||||
|
(fichier, courriel, entrée de l'annuaire, base, rôle, table, ligne d'historique, ou une
|
||||||
|
base dont plus aucune ligne d'avant ne subsiste) ou une **identité changée** : clés et
|
||||||
|
certificats de l'AC, AC d'Icinga et environnement d'Icinga DB, clé DKIM, `instanceid` de
|
||||||
|
Nextcloud, mot de passe d'une entrée de l'annuaire. PostgreSQL se compare par clé (première
|
||||||
|
colonne de chaque table), pas par nombre de lignes. Le reste (un courriel reçu depuis, une table de sessions, le bayes de rspamd,
|
||||||
|
un cache) est listé sans être un écart. La reconstruction les lance à son étape `bilan`.
|
||||||
|
|
||||||
|
Les gestes :
|
||||||
|
|
||||||
|
```
|
||||||
|
make sauvegarder-maintenant # JUSTE AVANT de raser : étiqueté « avant-raser »
|
||||||
|
make restauration-etat [HOTE=...] # ce que chaque jeu est devenu, et si son instantané est encore au dépôt
|
||||||
|
make temoins-etat [HOTE=...] # l'état vivant contre l'instantané d'avant rasage
|
||||||
|
make temoins-etat SOURCE=candidat # faute d'étiquette : contre le dernier d'avant la naissance
|
||||||
|
make restauration-renoncer HOTE=.. JEU=.. CONFIRMER=true # écarter un état, sans le remettre
|
||||||
|
-e client_backup_restauration_instantane=<id> # imposer un instantané, par hôte
|
||||||
|
```
|
||||||
|
|
||||||
|
Sur un nœud, sans Ansible : `setops-restaurer etat`, `setops-restaurer temoins`, et les répétitions qui ne touchent à
|
||||||
|
rien — `setops-restaurer annuaire --essai <base_dn>`, `base <nom> --vers epreuve`,
|
||||||
|
`fichiers --vers /var/tmp/epreuve <chemin>`.
|
||||||
|
|
||||||
> Éprouvé le 2026-08-12 sur Chezlepro. `make valider` rejoue la partie automatisable ;
|
> Éprouvé le 2026-08-12 sur Chezlepro. `make valider` rejoue la partie automatisable ;
|
||||||
> la restauration d'une base reste manuelle, et porte un piège décrit plus bas.
|
> la restauration d'une base reste manuelle, et porte un piège décrit plus bas.
|
||||||
|
|
||||||
|
|
@ -143,10 +201,11 @@ la base badger de step-ca, qui avance à **chaque** émission de certificat.
|
||||||
|
|
||||||
`pg_dumpall` écrit `CREATE DATABASE <suivante>` **avant** le `\connect` correspondant.
|
`pg_dumpall` écrit `CREATE DATABASE <suivante>` **avant** le `\connect` correspondant.
|
||||||
Découper « du `\connect X` au `\connect` suivant » emporte donc un ordre qui vise une
|
Découper « du `\connect X` au `\connect` suivant » emporte donc un ordre qui vise une
|
||||||
**autre** base. Couper aussi sur `CREATE DATABASE`, et **vérifier avant de rejouer** :
|
**autre** base. Couper aussi sur `CREATE DATABASE` et sur `DROP DATABASE` (avec `--clean`,
|
||||||
|
la section de `nextcloud` finissait par `DROP DATABASE postgres;` — 2026-09-30), et **vérifier avant de rejouer** :
|
||||||
|
|
||||||
```
|
```
|
||||||
awk '/^\\connect forgejo$/{f=1;next} f && (/^\\connect /||/^CREATE DATABASE /){exit} f' \
|
awk '/^\\connect forgejo$/{f=1;next} f && (/^\\connect /||/^CREATE DATABASE /||/^DROP DATABASE /){exit} f' \
|
||||||
toutes-bases.sql > section.sql
|
toutes-bases.sql > section.sql
|
||||||
|
|
||||||
grep -qE '^(DROP|CREATE|ALTER) DATABASE|^\\connect' section.sql \
|
grep -qE '^(DROP|CREATE|ALTER) DATABASE|^\\connect' section.sql \
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,10 @@ ce poste et sur le runner de son écosystème, qui en reçoit une copie chiffré
|
||||||
d'une seconde couche ; le script refuse tout fichier dont l'en-tête n'est pas
|
d'une seconde couche ; le script refuse tout fichier dont l'en-tête n'est pas
|
||||||
`$ANSIBLE_VAULT`, garde le chemin de chaque voûte (elles s'appellent presque toutes
|
`$ANSIBLE_VAULT`, garde le chemin de chaque voûte (elles s'appellent presque toutes
|
||||||
`vault.yml`) et relit l'archive empreinte par empreinte. Restauration :
|
`vault.yml`) et relit l'archive empreinte par empreinte. Restauration :
|
||||||
`tar -xf setops-voutes-*.tar -C <dossier des dépôts>`. **À refaire après toute écriture
|
`tar -xf "$(ls -t setops-voutes-*.tar | head -1)" -C <dossier des dépôts>` (la plus récente).
|
||||||
|
Chaque export porte sa date (`setops-voutes-<date>-<poste>.tar`, l'heure en plus au second
|
||||||
|
export du jour) et n'écrit rien si les voûtes n'ont pas changé depuis la dernière archive.
|
||||||
|
**À refaire après toute écriture
|
||||||
dans une voûte**, comme les clés après toute voûte nouvelle.
|
dans une voûte**, comme les clés après toute voûte nouvelle.
|
||||||
|
|
||||||
> **Ce chiffre était trois, et il a baissé sans que rien ne le signale.** Un coffre répliqué deux fois reste
|
> **Ce chiffre était trois, et il a baissé sans que rien ne le signale.** Un coffre répliqué deux fois reste
|
||||||
|
|
@ -77,7 +80,7 @@ Une procédure rangée dans le dépôt serait donc inaccessible exactement quand
|
||||||
La clé se suffit donc à elle-même. À côté de l'archive :
|
La clé se suffit donc à elle-même. À côté de l'archive :
|
||||||
|
|
||||||
```
|
```
|
||||||
setops-cles-<poste>.tar.gpg les clés, chiffrées
|
setops-cles-<date>-<poste>.tar.gpg les clés, chiffrées (une archive datée par export)
|
||||||
restaurer_cles.py le script, autonome
|
restaurer_cles.py le script, autonome
|
||||||
LISEZ-MOI-RESTAURATION.txt le mode d'emploi, et les commandes manuelles
|
LISEZ-MOI-RESTAURATION.txt le mode d'emploi, et les commandes manuelles
|
||||||
```
|
```
|
||||||
|
|
@ -89,8 +92,9 @@ LISEZ-MOI-RESTAURATION.txt le mode d'emploi, et les commandes manuelles
|
||||||
Sur la machine neuve, il ne faut que `gpg`, `python3` et la phrase de passe :
|
Sur la machine neuve, il ne faut que `gpg`, `python3` et la phrase de passe :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 restaurer_cles.py setops-cles-*.tar.gpg --lister # voir sans rien écrire
|
A="$(ls -t setops-cles-*.tar* | head -1)" # la plus récente
|
||||||
python3 restaurer_cles.py setops-cles-*.tar.gpg # remettre en place
|
python3 restaurer_cles.py "$A" --lister # voir sans rien écrire
|
||||||
|
python3 restaurer_cles.py "$A" # remettre en place
|
||||||
```
|
```
|
||||||
|
|
||||||
Le script remet chaque fichier à sa place selon son nom, et **repose les droits à 0600**.
|
Le script remet chaque fichier à sa place selon son nom, et **repose les droits à 0600**.
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,8 @@ vault_postgresql_keycloak: ""
|
||||||
vault_bd_keycloak: ""
|
vault_bd_keycloak: ""
|
||||||
vault_bd_forgejo: ""
|
vault_bd_forgejo: ""
|
||||||
vault_bd_icingadb: ""
|
vault_bd_icingadb: ""
|
||||||
# La base des COMPTES de la console (mode `db`) — distincte de celle du moteur.
|
# La base de la console (vigie) dans tous les modes : comptes en mode `db`, préférences
|
||||||
|
# et migrations partout — distincte de celle du moteur.
|
||||||
vault_bd_icingaweb2: ""
|
vault_bd_icingaweb2: ""
|
||||||
|
|
||||||
# --- Forge (Forgejo) ---
|
# --- Forge (Forgejo) ---
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ depots:
|
||||||
composant: main
|
composant: main
|
||||||
# Sur CHAQUE machine, via `client_pki`. Le paquet le plus critique de la liste.
|
# Sur CHAQUE machine, via `client_pki`. Le paquet le plus critique de la liste.
|
||||||
paquets:
|
paquets:
|
||||||
step-cli: 0.30.6-1
|
step-cli: 0.31.0-1
|
||||||
|
|
||||||
icinga:
|
icinga:
|
||||||
uri: https://packages.icinga.com/debian
|
uri: https://packages.icinga.com/debian
|
||||||
|
|
@ -37,12 +37,12 @@ depots:
|
||||||
icinga-archive-keyring: 2.0.0-1+debian13
|
icinga-archive-keyring: 2.0.0-1+debian13
|
||||||
icingacli: 2.14.0-2+debian13
|
icingacli: 2.14.0-2+debian13
|
||||||
icingadb: 1.5.1-8+debian13
|
icingadb: 1.5.1-8+debian13
|
||||||
icingadb-redis: 8.2.9-1+debian13
|
icingadb-redis: 8.2.10-1+debian13
|
||||||
icingadb-web: 1.4.0-1+debian13
|
icingadb-web: 1.4.0-1+debian13
|
||||||
icinga-l10n: 1.4.0-1+debian13
|
icinga-l10n: 1.4.0-1+debian13
|
||||||
icinga-php-legacy: 1.1.0-1+debian13
|
icinga-php-legacy: 1.1.0-1+debian13
|
||||||
icinga-php-library: 1.0.0-2+debian13
|
icinga-php-library: 1.0.1-1+debian13
|
||||||
icinga-php-thirdparty: 1.0.0-2+debian13
|
icinga-php-thirdparty: 1.0.1-1+debian13
|
||||||
icingaweb2: 2.14.0-2+debian13
|
icingaweb2: 2.14.0-2+debian13
|
||||||
icingaweb2-common: 2.14.0-2+debian13
|
icingaweb2-common: 2.14.0-2+debian13
|
||||||
icingaweb2-module-monitoring: 2.12.6-1+debian13
|
icingaweb2-module-monitoring: 2.12.6-1+debian13
|
||||||
|
|
@ -53,7 +53,12 @@ depots:
|
||||||
suite: stable
|
suite: stable
|
||||||
composant: main
|
composant: main
|
||||||
# `alloy` est universel comme `step-cli` : `client_metrique` le pose partout.
|
# `alloy` est universel comme `step-cli` : `client_metrique` le pose partout.
|
||||||
|
# RELEVES LE 2026-10-03 sur ce que portent les locataires depuis leur reconstruction du
|
||||||
|
# 2026-10-01 : leurs runners n'ont pas ce cache et avaient tire la derniere version
|
||||||
|
# publiee. Le cache du poste, lui, servait encore les anciennes — un deploiement depuis
|
||||||
|
# le poste aurait tente de les RETROGRADER (`paquets_tiers` installe les .deb du cache).
|
||||||
|
# Le site, encore en 1.19.2 / 13.2.1 / 3.7.7, montera a son prochain passage.
|
||||||
paquets:
|
paquets:
|
||||||
alloy: 1.19.2-1
|
alloy: 1.20.1-1
|
||||||
grafana: 13.2.1
|
grafana: 13.2.3
|
||||||
loki: 3.7.7
|
loki: 3.7.8
|
||||||
|
|
|
||||||
121
playbooks/backup/restauration.yml
Normal file
121
playbooks/backup/restauration.yml
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
---
|
||||||
|
# L'ETAT D'UNE INCARNATION PRECEDENTE — voir roles/client_backup/templates/restaurer.sh.j2
|
||||||
|
#
|
||||||
|
# make restauration-etat [HOTE=...]
|
||||||
|
# ce qui attend dans le depot, et ce que chaque jeu est devenu (lecture seule) ;
|
||||||
|
# make sauvegarder-maintenant [HOTE=...]
|
||||||
|
# deposer TOUT DE SUITE, et attendre que ce soit fait. A faire juste avant `raser` :
|
||||||
|
# sinon la reconstruction remet l'etat de la derniere nuit, et perd la journee ;
|
||||||
|
# make temoins-etat [HOTE=...] [SOURCE=candidat]
|
||||||
|
# l'etat VIVANT est-il celui d'avant le rasage ? Compare a l'instantane etiquete
|
||||||
|
# « avant-raser » (lecture seule). Une perte, ou une identite changee (cles de l'AC,
|
||||||
|
# DKIM, instanceid, mot de passe d'une entree de l'annuaire), fait echouer le jeu ;
|
||||||
|
# make restauration-renoncer HOTE=<hote> JEU=<jeu> CONFIRMER=true
|
||||||
|
# ECARTER un etat anterieur sans le remettre. La sauvegarde du noeud, qui refusait
|
||||||
|
# de deposer pour ne pas le chasser de la retention, reprend — et l'etat d'avant
|
||||||
|
# finira par sortir de la retention. C'est une decision, pas une reparation.
|
||||||
|
#
|
||||||
|
# La REMISE, elle, n'a pas de cible : elle est faite par le role proprietaire de l'etat,
|
||||||
|
# au deploiement, au moment ou il le creerait neuf.
|
||||||
|
- name: L'etat d'une incarnation precedente
|
||||||
|
hosts: client_backup
|
||||||
|
become: true
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
restauration_action: etat
|
||||||
|
restauration_jeu: ""
|
||||||
|
restauration_source: avant-raser
|
||||||
|
tasks:
|
||||||
|
- name: Refuser un renoncement sans jeu nomme
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- restauration_jeu | length > 0
|
||||||
|
fail_msg: "JEU=<jeu> requis (voir `make restauration-etat` pour les noms)."
|
||||||
|
when: restauration_action == 'renoncer'
|
||||||
|
|
||||||
|
- name: Lire ce qui attend, et ce qui a ete tranche
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv: [/usr/local/sbin/setops-restaurer, etat]
|
||||||
|
register: restauration_lue
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
when: restauration_action == 'etat'
|
||||||
|
|
||||||
|
- name: Etat
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: "{{ (restauration_lue.stdout_lines | default([])) + (restauration_lue.stderr_lines | default([])) }}"
|
||||||
|
when: restauration_action == 'etat'
|
||||||
|
|
||||||
|
# L'unite est `oneshot` : `systemctl start` rend la main quand le depot est fait, et
|
||||||
|
# echoue s'il a echoue — y compris quand la garde refuse (etat d'avant non remis).
|
||||||
|
- name: Ce noeud a-t-il quelque chose a deposer ?
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: /etc/systemd/system/setops-sauvegarde.service
|
||||||
|
register: restauration_unite
|
||||||
|
when: restauration_action == 'sauvegarder'
|
||||||
|
|
||||||
|
# LE DEPOT D'AVANT RASAGE EST ETIQUETE (2026-10-07), et la retention le garde jusqu'a la
|
||||||
|
# reconstruction suivante. Un noeud qui n'a pas encore cette unite deposerait SANS
|
||||||
|
# etiquette, et la premiere sauvegarde d'apres reconstruction le chasserait : c'est
|
||||||
|
# exactement ce qui s'est passe a M4. On refuse plutot que de deposer a moitie.
|
||||||
|
- name: L'unite du depot d'avant rasage est-elle deployee ?
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: /etc/systemd/system/setops-sauvegarde-avant-raser.service
|
||||||
|
register: restauration_unite_avant_raser
|
||||||
|
when:
|
||||||
|
- restauration_action == 'sauvegarder'
|
||||||
|
- restauration_unite.stat.exists
|
||||||
|
|
||||||
|
- name: Refuser un depot que la retention ne garderait pas
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- restauration_unite_avant_raser.stat.exists
|
||||||
|
fail_msg: >-
|
||||||
|
setops-sauvegarde-avant-raser.service absente : redeployer client_backup
|
||||||
|
(make appliquer GROUPE=client_backup) avant de raser.
|
||||||
|
when:
|
||||||
|
- restauration_action == 'sauvegarder'
|
||||||
|
- restauration_unite.stat.exists
|
||||||
|
|
||||||
|
- name: Deposer maintenant, etiquete avant rasage
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: setops-sauvegarde-avant-raser.service
|
||||||
|
state: started
|
||||||
|
when:
|
||||||
|
- restauration_action == 'sauvegarder'
|
||||||
|
- restauration_unite.stat.exists
|
||||||
|
|
||||||
|
# Code 4 = rien a comparer (aucun etat, ou aucun instantane d'avant rasage) : ce n'est
|
||||||
|
# pas un echec. Code 1 = ecart : on affiche d'abord, on echoue ensuite.
|
||||||
|
- name: Temoins — l'etat vivant contre l'instantane d'avant rasage
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv: >-
|
||||||
|
{{ ['/usr/local/sbin/setops-restaurer', 'temoins']
|
||||||
|
+ (['--candidat'] if restauration_source == 'candidat' else []) }}
|
||||||
|
register: restauration_temoins
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
when: restauration_action == 'temoins'
|
||||||
|
|
||||||
|
- name: Temoins
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: "{{ (restauration_temoins.stdout_lines | default([])) + (restauration_temoins.stderr_lines | default([])) }}"
|
||||||
|
when: restauration_action == 'temoins'
|
||||||
|
|
||||||
|
- name: Temoins — verdict
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- restauration_temoins.rc in [0, 4]
|
||||||
|
fail_msg: >-
|
||||||
|
{{ 'ECART : une perte, ou une identite changee (voir ci-dessus).'
|
||||||
|
if restauration_temoins.rc == 1 else
|
||||||
|
'setops-restaurer temoins a echoue (code ' ~ restauration_temoins.rc ~ ') : '
|
||||||
|
~ 'client_backup est-il deploye ?' }}
|
||||||
|
quiet: true
|
||||||
|
when: restauration_action == 'temoins'
|
||||||
|
|
||||||
|
- name: Ecarter l'etat anterieur de ce jeu
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv: [/usr/local/sbin/setops-restaurer, acter, "{{ restauration_jeu }}", abandonne]
|
||||||
|
changed_when: true
|
||||||
|
when: restauration_action == 'renoncer'
|
||||||
|
|
@ -37,3 +37,20 @@
|
||||||
pre_tasks: *pre_sante
|
pre_tasks: *pre_sante
|
||||||
roles:
|
roles:
|
||||||
- client_sante
|
- client_sante
|
||||||
|
|
||||||
|
# LE RETRAIT, SUR LES HOTES SORTIS DU GROUPE (2026-10-03). Un role qu'on cesse d'appliquer
|
||||||
|
# laisse son minuteur derriere lui : les hyperviseurs du site ont pousse vers une adresse
|
||||||
|
# perimee pendant des semaines (voir `roles/client_sante/tasks/retirer.yml`).
|
||||||
|
#
|
||||||
|
# LE GROUPE EST NOMME, pas deduit (`all:!client_sante`) : un hyperviseur n'est pas une VM
|
||||||
|
# de la flotte, et un play qui le touche doit le dire. Chez un tenant, `hyperviseurs`
|
||||||
|
# n'existe pas et ce play ne trouve personne.
|
||||||
|
- name: Intégration client_sante — retrait sur les hyperviseurs qui n'y sont plus
|
||||||
|
hosts: hyperviseurs:!client_sante
|
||||||
|
become: true
|
||||||
|
gather_facts: false
|
||||||
|
tasks:
|
||||||
|
- name: Retirer le porteur de santé
|
||||||
|
ansible.builtin.include_role:
|
||||||
|
name: client_sante
|
||||||
|
tasks_from: retirer.yml
|
||||||
|
|
|
||||||
43
playbooks/maintenance/forge_cles.yml
Normal file
43
playbooks/maintenance/forge_cles.yml
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
---
|
||||||
|
# LE SITE ENREGISTRE LA CLE DE LECTURE DE CHAQUE LOCATAIRE SUR SON DEPOT (2026-10-11).
|
||||||
|
#
|
||||||
|
# Voir `docs/depots-prives-conception.md` et `scripts/forge_cles.py`. Le script tourne SUR le
|
||||||
|
# runner du site : il y lit les faces que le site tire (et rien d'autre de ses locataires), et
|
||||||
|
# il y joint la forge. Le mot de passe d'administration vient de la voute du site, par
|
||||||
|
# l'environnement, et n'est jamais affiche.
|
||||||
|
#
|
||||||
|
# make forge-cles montre ce qui serait fait
|
||||||
|
# make forge-cles CONFIRMER=true l'ecrit, puis relit
|
||||||
|
- name: Les cles de lecture des locataires, sur la forge du site
|
||||||
|
hosts: serveur_ops_site
|
||||||
|
gather_facts: false
|
||||||
|
become: true
|
||||||
|
become_user: "{{ serveur_ops_utilisateur | default('setops') }}"
|
||||||
|
vars:
|
||||||
|
forge_cles_racine: "{{ serveur_ops_racine | default('/opt/setops') }}"
|
||||||
|
forge_cles_moteur: "{{ (serveur_ops_depots | selectattr('role', 'eq', 'moteur') | first).dest }}"
|
||||||
|
forge_cles_confirmer: "{{ lookup('env', 'CONFIRMER') == 'true' }}"
|
||||||
|
tasks:
|
||||||
|
- name: Exiger le mot de passe d'administration de la forge (voute du site)
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that: vault_forgejo_admin | default('') | length > 0
|
||||||
|
fail_msg: "vault_forgejo_admin absent : la voute du site n'est pas chargee."
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Confronter les faces aux cles de la forge
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv: "{{ ['python3', 'scripts/forge_cles.py']
|
||||||
|
+ (['--confirmer'] if (forge_cles_confirmer | bool) else []) }}"
|
||||||
|
chdir: "{{ forge_cles_racine }}/{{ forge_cles_moteur }}"
|
||||||
|
environment:
|
||||||
|
FORGEJO_ADMIN: "{{ vault_forgejo_admin }}"
|
||||||
|
PATH: "{{ forge_cles_racine }}/venv/bin:/usr/local/bin:/usr/bin:/bin"
|
||||||
|
# En simulation, le script ne fait que montrer (sans --confirmer) : il peut tourner.
|
||||||
|
check_mode: false
|
||||||
|
register: forge_cles
|
||||||
|
changed_when: "'ajoutee' in forge_cles.stdout or 'retiree' in forge_cles.stdout or 'rendu ' in forge_cles.stdout"
|
||||||
|
failed_when: forge_cles.rc not in [0]
|
||||||
|
|
||||||
|
- name: Ce que la forge porte
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: "{{ forge_cles.stdout_lines }}"
|
||||||
46
playbooks/maintenance/forge_comptes.yml
Normal file
46
playbooks/maintenance/forge_comptes.yml
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
---
|
||||||
|
# LES COMPTES DE LA FORGE DU SITE (2026-10-11, lot 3b).
|
||||||
|
#
|
||||||
|
# Voir `docs/depots-prives-conception.md` et `scripts/forge_comptes.py`. Le script tourne SUR le
|
||||||
|
# runner du site : il y lit le plan du site (`mainteneurs`) et les faces de ses locataires, et
|
||||||
|
# il y joint la forge. Le mot de passe d'administration vient de la voute du site, par
|
||||||
|
# l'environnement, et n'est jamais affiche.
|
||||||
|
#
|
||||||
|
# make forge-comptes montre ce qui serait fait
|
||||||
|
# make forge-comptes CONFIRMER=true l'ecrit, puis relit
|
||||||
|
- name: Les comptes de la forge du site
|
||||||
|
hosts: serveur_ops_site
|
||||||
|
gather_facts: false
|
||||||
|
become: true
|
||||||
|
become_user: "{{ serveur_ops_utilisateur | default('setops') }}"
|
||||||
|
vars:
|
||||||
|
forge_comptes_racine: "{{ serveur_ops_racine | default('/opt/setops') }}"
|
||||||
|
forge_comptes_moteur: "{{ (serveur_ops_depots | selectattr('role', 'eq', 'moteur') | first).dest }}"
|
||||||
|
forge_comptes_confirmer: "{{ lookup('env', 'CONFIRMER') == 'true' }}"
|
||||||
|
tasks:
|
||||||
|
- name: Exiger le mot de passe d'administration de la forge (voute du site)
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that: vault_forgejo_admin | default('') | length > 0
|
||||||
|
fail_msg: "vault_forgejo_admin absent : la voute du site n'est pas chargee."
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Confronter le plan du site aux comptes de la forge
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv: "{{ ['python3', 'scripts/forge_comptes.py']
|
||||||
|
+ (['--confirmer'] if (forge_comptes_confirmer | bool) else []) }}"
|
||||||
|
chdir: "{{ forge_comptes_racine }}/{{ forge_comptes_moteur }}"
|
||||||
|
environment:
|
||||||
|
FORGEJO_ADMIN: "{{ vault_forgejo_admin }}"
|
||||||
|
PATH: "{{ forge_comptes_racine }}/venv/bin:/usr/local/bin:/usr/bin:/bin"
|
||||||
|
# En simulation, le script ne fait que montrer (sans --confirmer) : il peut tourner.
|
||||||
|
check_mode: false
|
||||||
|
register: forge_comptes
|
||||||
|
changed_when: >-
|
||||||
|
['cree ', 'collab ', 'retire ', 'jeton '] | select('in', forge_comptes.stdout) | list | length > 0
|
||||||
|
# 1 = un ecart DIT (un courriel a remplir, par exemple) : il s'affiche, il n'empeche pas
|
||||||
|
# le reste. 2 = le script a refuse de travailler.
|
||||||
|
failed_when: forge_comptes.rc not in [0, 1]
|
||||||
|
|
||||||
|
- name: Ce que la forge porte
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: "{{ forge_comptes.stdout_lines }}"
|
||||||
|
|
@ -61,6 +61,11 @@
|
||||||
url: >-
|
url: >-
|
||||||
https://forge.{{ domaine_interne }}/api/v1/repos/{{ serveur_ops_forge_organisation
|
https://forge.{{ domaine_interne }}/api/v1/repos/{{ serveur_ops_forge_organisation
|
||||||
| default('genome') }}/{{ item.depot }}/branches/{{ item.branche | default('main') }}
|
| default('genome') }}/{{ item.depot }}/branches/{{ item.branche | default('main') }}
|
||||||
|
# AUTHENTIFIE (2026-10-11) : les depots des locataires et du site deviennent prives, et
|
||||||
|
# l'API anonyme y repondrait 404 — un « absent » qui n'en serait pas un.
|
||||||
|
url_username: forgejo-admin
|
||||||
|
url_password: "{{ vault_forgejo_admin }}"
|
||||||
|
force_basic_auth: true
|
||||||
validate_certs: false
|
validate_certs: false
|
||||||
status_code: [200, 404]
|
status_code: [200, 404]
|
||||||
loop: "{{ genome_compares }}"
|
loop: "{{ genome_compares }}"
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,7 @@
|
||||||
loop: "{{ genome_a_pousser }}"
|
loop: "{{ genome_a_pousser }}"
|
||||||
loop_control:
|
loop_control:
|
||||||
label: "{{ item.dest }}"
|
label: "{{ item.dest }}"
|
||||||
|
register: genome_emballe
|
||||||
changed_when: true
|
changed_when: true
|
||||||
|
|
||||||
- name: Porter les colis jusqu'au runner
|
- name: Porter les colis jusqu'au runner
|
||||||
|
|
@ -215,11 +216,16 @@
|
||||||
|
|
||||||
- name: Demander à la forge ce qu'elle porte maintenant
|
- name: Demander à la forge ce qu'elle porte maintenant
|
||||||
# On interroge son API plutôt que `git ls-remote` : c'est la forge elle-même qui
|
# On interroge son API plutôt que `git ls-remote` : c'est la forge elle-même qui
|
||||||
# répond, et la lecture est anonyme — aucun justificatif n'entre ici.
|
# répond. Lecture authentifiée depuis le 2026-10-11 (dépôts privés).
|
||||||
ansible.builtin.uri:
|
ansible.builtin.uri:
|
||||||
url: >-
|
url: >-
|
||||||
https://forge.{{ domaine_interne }}/api/v1/repos/{{ serveur_ops_forge_organisation
|
https://forge.{{ domaine_interne }}/api/v1/repos/{{ serveur_ops_forge_organisation
|
||||||
| default('genome') }}/{{ item.depot }}/branches/{{ item.branche | default('main') }}
|
| default('genome') }}/{{ item.depot }}/branches/{{ item.branche | default('main') }}
|
||||||
|
# AUTHENTIFIE (2026-10-11) : les depots des locataires et du site deviennent prives, et
|
||||||
|
# l'API anonyme y repondrait 404 — un « absent » qui n'en serait pas un.
|
||||||
|
url_username: forgejo-admin
|
||||||
|
url_password: "{{ vault_forgejo_admin }}"
|
||||||
|
force_basic_auth: true
|
||||||
validate_certs: false
|
validate_certs: false
|
||||||
status_code: [200]
|
status_code: [200]
|
||||||
loop: "{{ genome_a_pousser }}"
|
loop: "{{ genome_a_pousser }}"
|
||||||
|
|
@ -255,6 +261,50 @@
|
||||||
label: "{{ item.depot }}"
|
label: "{{ item.depot }}"
|
||||||
index_var: idx
|
index_var: idx
|
||||||
|
|
||||||
|
# LES RELEASES AUSSI (2026-10-07). Le colis ne portait que la branche : `v2026.08.21` et
|
||||||
|
# `v2026.10.07` manquaient a la forge du site. Il porte desormais les etiquettes ; on
|
||||||
|
# demande a la forge si elle a TOUTES celles du poste. 404 = aucune etiquette sur ce
|
||||||
|
# depot, ce qui est juste quand le poste n'en a pas non plus.
|
||||||
|
- name: Demander à la forge les étiquettes qu'elle porte
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: >-
|
||||||
|
https://forge.{{ domaine_interne }}/api/v1/repos/{{ serveur_ops_forge_organisation
|
||||||
|
| default('genome') }}/{{ item.depot }}/git/refs/tags
|
||||||
|
# AUTHENTIFIE (2026-10-11) : les depots des locataires et du site deviennent prives, et
|
||||||
|
# l'API anonyme y repondrait 404 — un « absent » qui n'en serait pas un.
|
||||||
|
url_username: forgejo-admin
|
||||||
|
url_password: "{{ vault_forgejo_admin }}"
|
||||||
|
force_basic_auth: true
|
||||||
|
validate_certs: false
|
||||||
|
status_code: [200, 404]
|
||||||
|
loop: "{{ genome_a_pousser }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.depot }}"
|
||||||
|
register: genome_forge_etiquettes
|
||||||
|
retries: 4
|
||||||
|
delay: 3
|
||||||
|
until: genome_forge_etiquettes is succeeded
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: La forge porte-t-elle toutes les releases du poste ?
|
||||||
|
vars:
|
||||||
|
genome_etiquettes_poste: "{{ (genome_emballe.results[idx].stdout | from_json).etiquettes | default([]) }}"
|
||||||
|
genome_etiquettes_forge: >-
|
||||||
|
{{ (genome_forge_etiquettes.results[idx].json | default([]) if genome_forge_etiquettes.results[idx].status == 200 else [])
|
||||||
|
| map(attribute='ref') | map('regex_replace', '^refs/tags/', '') | list }}
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- genome_etiquettes_poste | difference(genome_etiquettes_forge) | length == 0
|
||||||
|
fail_msg: >-
|
||||||
|
{{ item.depot }} : la forge n'a pas
|
||||||
|
{{ genome_etiquettes_poste | difference(genome_etiquettes_forge) | join(', ') }}.
|
||||||
|
success_msg: >-
|
||||||
|
{{ item.depot }} : {{ genome_etiquettes_poste | length }} etiquette(s) du poste, toutes sur la forge.
|
||||||
|
loop: "{{ genome_a_pousser }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.depot }}"
|
||||||
|
index_var: idx
|
||||||
|
|
||||||
# LE RUNNER DU SITE AVANCE SON PROPRE CLONE, ET SA CONSOLE NE LE SAIT PAS (2026-09-20).
|
# LE RUNNER DU SITE AVANCE SON PROPRE CLONE, ET SA CONSOLE NE LE SAIT PAS (2026-09-20).
|
||||||
#
|
#
|
||||||
# C'est LUI qui pousse, donc il doit d'abord recevoir : ce playbook fait avancer le
|
# C'est LUI qui pousse, donc il doit d'abord recevoir : ce playbook fait avancer le
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,23 @@
|
||||||
gather_facts: false
|
gather_facts: false
|
||||||
become: false
|
become: false
|
||||||
tasks:
|
tasks:
|
||||||
- name: Interroger l'API des cibles actives
|
# UN PROMETHEUS QUI VIENT DE REDEMARRER N'A PAS ENCORE RELEVE SES CIBLES (2026-10-08).
|
||||||
|
# Depuis qu'obs-01 est sauvegarde, son premier depot (copie a froid, en fin de
|
||||||
|
# deploiement) relance Prometheus : a la reconstruction de Technolibre, la recette l'a
|
||||||
|
# interroge 18 s apres, et deux cibles n'avaient pas encore de releve reussi — saines
|
||||||
|
# quelques minutes plus tard. On redemande donc, une minute au plus, avant de juger :
|
||||||
|
# une vraie panne echoue toujours, une minute plus tard.
|
||||||
|
- name: Interroger l'API des cibles actives (jusqu'a ce que toutes soient relevees, une minute au plus)
|
||||||
ansible.builtin.uri:
|
ansible.builtin.uri:
|
||||||
url: "http://localhost:9090/api/v1/targets?state=active"
|
url: "http://localhost:9090/api/v1/targets?state=active"
|
||||||
return_content: true
|
return_content: true
|
||||||
register: prom_targets
|
register: prom_targets
|
||||||
|
until: >-
|
||||||
|
((prom_targets.content | default('{}') | from_json).get('data', {}).get('activeTargets', [])
|
||||||
|
| rejectattr('health', 'equalto', 'up') | list | length == 0)
|
||||||
|
or (prom_targets.attempts | default(0) | int >= 6)
|
||||||
|
retries: 6
|
||||||
|
delay: 10
|
||||||
|
|
||||||
- name: Analyser up / down
|
- name: Analyser up / down
|
||||||
vars:
|
vars:
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,15 @@
|
||||||
name: auditd
|
name: auditd
|
||||||
state: restarted
|
state: restarted
|
||||||
failed_when: false
|
failed_when: false
|
||||||
|
|
||||||
|
# REDEMARRER `auditd` NE RECHARGE PAS LES REGLES (mesure du 2026-10-03, Debian 13).
|
||||||
|
#
|
||||||
|
# Elles sont chargees par `audit-rules.service` (`augenrules --load`), une unite a part
|
||||||
|
# dont `auditd` depend — et qu'un redemarrage d'`auditd` ne rejoue pas. Le handler
|
||||||
|
# ci-dessus laissait donc le fichier juste et le noyau sur les regles du dernier demarrage.
|
||||||
|
#
|
||||||
|
# SANS `failed_when: false` : une regle que le noyau refuse doit se voir ici, pas au
|
||||||
|
# prochain redemarrage de la machine.
|
||||||
|
- name: Recharger les regles auditd
|
||||||
|
ansible.builtin.command: augenrules --load
|
||||||
|
changed_when: true
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,9 @@
|
||||||
path: "/etc/audit/rules.d/{{ item }}"
|
path: "/etc/audit/rules.d/{{ item }}"
|
||||||
state: absent
|
state: absent
|
||||||
loop: "{{ auditd_fichiers_perimes }}"
|
loop: "{{ auditd_fichiers_perimes }}"
|
||||||
notify: Restart auditd
|
notify:
|
||||||
|
- Restart auditd
|
||||||
|
- Recharger les regles auditd
|
||||||
when: auditd_enabled | bool
|
when: auditd_enabled | bool
|
||||||
|
|
||||||
# LA RETENTION EST UN CHOIX, DONC ELLE S'ECRIT (2026-09-11). Voir defaults/main.yml pour
|
# LA RETENTION EST UN CHOIX, DONC ELLE S'ECRIT (2026-09-11). Voir defaults/main.yml pour
|
||||||
|
|
@ -48,7 +50,9 @@
|
||||||
owner: root
|
owner: root
|
||||||
group: root
|
group: root
|
||||||
mode: "0640"
|
mode: "0640"
|
||||||
notify: Restart auditd
|
notify:
|
||||||
|
- Restart auditd
|
||||||
|
- Recharger les regles auditd
|
||||||
when: auditd_enabled | bool
|
when: auditd_enabled | bool
|
||||||
|
|
||||||
# `failed_when: false` A CACHE LA PANNE PENDANT TOUT UN DEPLOIEMENT (2026-08-30).
|
# `failed_when: false` A CACHE LA PANNE PENDANT TOUT UN DEPLOIEMENT (2026-08-30).
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,20 @@
|
||||||
-w /etc/step-ca/ -p wa -k pki-autorite
|
-w /etc/step-ca/ -p wa -k pki-autorite
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
# L'EXPORTATEUR DE METRIQUES LIT L'HORLOGE, IL NE LA REGLE PAS (2026-10-03).
|
||||||
|
#
|
||||||
|
# `adjtimex` sert aux deux : regler ET lire. Le collecteur `timex` de `node_exporter` le lit
|
||||||
|
# a chaque collecte, et la regle `time-change` ci-dessous ne peut pas distinguer — le mode
|
||||||
|
# est dans une structure pointee, hors de portee d'un filtre `-F`. Mesure au site : 4
|
||||||
|
# evenements par minute, 35 % de toute la piste d'audit d'une VM, pour un appel en lecture.
|
||||||
|
#
|
||||||
|
# UNE REGLE `never` ETROITE, ET DEVANT : l'audit retient la PREMIERE regle qui correspond.
|
||||||
|
# Elle ne vise que `adjtimex`, que ce binaire-la : `settimeofday`/`clock_settime` restent
|
||||||
|
# audites pour lui aussi, et chrony — qui regle vraiment — reste entierement audite.
|
||||||
|
# Eprouve avant d'ecrire : 4 evenements/min sans elle, 0 avec ; et `auditctl` accepte un
|
||||||
|
# `exe=` absent, donc la ligne ne casse pas le chargement d'une machine sans exportateur
|
||||||
|
# (ce que ferait un `-w` vers un chemin absent, voir plus haut).
|
||||||
|
-a never,exit -F arch=b64 -S adjtimex -F exe=/usr/bin/prometheus-node-exporter
|
||||||
-a always,exit -F arch=b64 -S adjtimex,settimeofday,clock_settime -k time-change
|
-a always,exit -F arch=b64 -S adjtimex,settimeofday,clock_settime -k time-change
|
||||||
-a always,exit -F arch=b64 -S sethostname,setdomainname -k system-locale
|
-a always,exit -F arch=b64 -S sethostname,setdomainname -k system-locale
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ Sans eux, le rôle refuse de s'exécuter (assertion).
|
||||||
| `client_backup_repo` | `sftp:restic@<cible>:<hôte>` | Un sous-dossier par nœud |
|
| `client_backup_repo` | `sftp:restic@<cible>:<hôte>` | Un sous-dossier par nœud |
|
||||||
| `client_backup_staging` | `/var/backups/setops` | Préparation des dumps |
|
| `client_backup_staging` | `/var/backups/setops` | Préparation des dumps |
|
||||||
| `client_backup_retention` | `--keep-daily 7 --keep-weekly 4 --keep-monthly 6` | `restic forget` |
|
| `client_backup_retention` | `--keep-daily 7 --keep-weekly 4 --keep-monthly 6` | `restic forget` |
|
||||||
|
| `client_backup_etiquette_avant_raser` | `avant-raser` | étiquette du dépôt d'avant rasage, gardée par la rétention jusqu'à la reconstruction suivante |
|
||||||
| `client_backup_horaire` | `*-*-* 02:30:00` | Timer systemd |
|
| `client_backup_horaire` | `*-*-* 02:30:00` | Timer systemd |
|
||||||
| `client_backup_jobs` | `[]` | **À déclarer par nœud** |
|
| `client_backup_jobs` | `[]` | **À déclarer par nœud** |
|
||||||
|
|
||||||
|
|
@ -55,7 +56,11 @@ Sans eux, le rôle refuse de s'exécuter (assertion).
|
||||||
vérifier que chaque nœud porteur d'état déclare ses jobs.
|
vérifier que chaque nœud porteur d'état déclare ses jobs.
|
||||||
- Un dépôt neuf est **vide jusqu'à la première exécution** : après une reconstruction
|
- Un dépôt neuf est **vide jusqu'à la première exécution** : après une reconstruction
|
||||||
from-zero, déclencher une première sauvegarde plutôt que d'attendre 02:30.
|
from-zero, déclencher une première sauvegarde plutôt que d'attendre 02:30.
|
||||||
- Restauration : `restic restore` avec le même mot de passe (cf. `docs/runbooks-exploitation.md`).
|
- Restauration : **faite par la reconstruction elle-même** depuis le 2026-09-30 — chaque rôle
|
||||||
|
propriétaire inclut `tasks/restaurer.yml` et remet l'état de l'incarnation précédente ;
|
||||||
|
l'outil de nœud est `setops-restaurer` (cf. `docs/runbooks-exploitation.md` §5.0).
|
||||||
|
- Témoins : `setops-restaurer temoins` (comparateur `setops-temoins`) confronte l'état vivant
|
||||||
|
à l'instantané d'avant rasage ; `make temoins-etat`, et l'étape `bilan` de la reconstruction.
|
||||||
|
|
||||||
## Prérequis
|
## Prérequis
|
||||||
- `serveur_backup` déployé, et sa `serveur_backup_pubkey` correspondant à la clé privée de
|
- `serveur_backup` déployé, et sa `serveur_backup_pubkey` correspondant à la clé privée de
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
# Chaque nœud déclare ses "jobs" ; restic chiffre côté client + applique la rétention.
|
# Chaque nœud déclare ses "jobs" ; restic chiffre côté client + applique la rétention.
|
||||||
client_backup_paquets:
|
client_backup_paquets:
|
||||||
- restic
|
- restic
|
||||||
|
# `setops-restaurer` remet les repertoires par `rsync --delete` (absent du gabarit).
|
||||||
|
- rsync
|
||||||
|
|
||||||
# Cible (nœud serveur_backup) + transport SSH.
|
# Cible (nœud serveur_backup) + transport SSH.
|
||||||
client_backup_cible: "backup-01.{{ domaine_interne }}"
|
client_backup_cible: "backup-01.{{ domaine_interne }}"
|
||||||
|
|
@ -18,6 +20,10 @@ client_backup_staging: "/var/backups/setops"
|
||||||
|
|
||||||
# Rétention (restic forget).
|
# Rétention (restic forget).
|
||||||
client_backup_retention: "--keep-daily 7 --keep-weekly 4 --keep-monthly 6"
|
client_backup_retention: "--keep-daily 7 --keep-weekly 4 --keep-monthly 6"
|
||||||
|
# L'instantane d'avant rasage (`make sauvegarder-maintenant`) porte cette etiquette, et la
|
||||||
|
# retention la garde EN PLUS de la regle ci-dessus : jusqu'a la reconstruction suivante
|
||||||
|
# (decision de l'exploitant, 2026-10-07). Voir `templates/sauvegarder.sh.j2`.
|
||||||
|
client_backup_etiquette_avant_raser: "avant-raser"
|
||||||
|
|
||||||
# Planification (timer systemd).
|
# Planification (timer systemd).
|
||||||
client_backup_horaire: "*-*-* 02:30:00"
|
client_backup_horaire: "*-*-* 02:30:00"
|
||||||
|
|
@ -74,9 +80,42 @@ client_backup_catalogue:
|
||||||
# qu'il a appris. Les regles, elles, se redeploient et ne sont pas ici.
|
# qu'il a appris. Les regles, elles, se redeploient et ne sont pas ici.
|
||||||
nom: rspamd
|
nom: rspamd
|
||||||
chemins: ["/var/lib/rspamd"]
|
chemins: ["/var/lib/rspamd"]
|
||||||
serveur_web_frontal:
|
# PAS DE `serveur_web_frontal` (retire le 2026-09-30) : le frontal RELAIE, il ne sert
|
||||||
nom: web_frontal
|
# rien lui-meme — ses vhosts, son WAF et sa page 404 se redeploient depuis le depot.
|
||||||
chemins: ["{{ serveur_web_frontal_racine_base | default('/srv/web') }}"]
|
# `/srv/web` venait de l'epoque ou il servait du statique ; ses instantanes etaient vides.
|
||||||
|
# LES METRIQUES ET LES JOURNAUX (2026-10-07). Ils repartaient de zero a chaque
|
||||||
|
# reconstruction. Ils s'ecrivent en continu : une copie A FROID (service arrete le temps
|
||||||
|
# de copier, `setops-copie-a-froid`) les pose dans le repertoire de preparation, et c'est
|
||||||
|
# cette copie que restic emporte. `vivant` dit ou vit la base, pour la restauration et les
|
||||||
|
# temoins, qui la comparent a l'instantane.
|
||||||
|
serveur_prometheus:
|
||||||
|
nom: prometheus
|
||||||
|
vivant: "{{ serveur_prometheus_donnees | default('/var/lib/prometheus/metrics2') }}"
|
||||||
|
commande: >-
|
||||||
|
/usr/local/sbin/setops-copie-a-froid
|
||||||
|
{{ serveur_prometheus_service | default('prometheus') }}
|
||||||
|
{{ serveur_prometheus_donnees | default('/var/lib/prometheus/metrics2') }}
|
||||||
|
{{ client_backup_staging }}/prometheus/metrics2 --blocs-immuables
|
||||||
|
chemins: ["{{ client_backup_staging }}/prometheus"]
|
||||||
|
serveur_loki:
|
||||||
|
nom: loki
|
||||||
|
vivant: "{{ serveur_loki_chemin | default('/var/lib/loki') }}"
|
||||||
|
commande: >-
|
||||||
|
/usr/local/sbin/setops-copie-a-froid
|
||||||
|
{{ serveur_loki_service | default('loki') }}
|
||||||
|
{{ serveur_loki_chemin | default('/var/lib/loki') }}
|
||||||
|
{{ client_backup_staging }}/loki/donnees --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:
|
serveur_web_dorsal:
|
||||||
nom: web_dorsal
|
nom: web_dorsal
|
||||||
chemins: ["{{ serveur_web_dorsal_racine | default('/srv/webapp') }}"]
|
chemins: ["{{ serveur_web_dorsal_racine | default('/srv/webapp') }}"]
|
||||||
|
|
@ -111,6 +150,8 @@ client_backup_icinga_hote: "{{ (groups['serveur_icinga'] | default([]) | first)
|
||||||
client_backup_icinga_utilisateur: "setops-depot"
|
client_backup_icinga_utilisateur: "setops-depot"
|
||||||
client_backup_icinga_motdepasse: "{{ vault_icinga_api_depot | default('') }}"
|
client_backup_icinga_motdepasse: "{{ vault_icinga_api_depot | default('') }}"
|
||||||
client_backup_ca_verification: "/etc/setops/icinga-ca.crt"
|
client_backup_ca_verification: "/etc/setops/icinga-ca.crt"
|
||||||
|
# L'Icinga auquel ce noeud a deja rapporte (empreinte de son AC) — voir tasks/verifier.yml.
|
||||||
|
client_backup_marque_icinga: "/etc/setops/client_backup-icinga-entendu"
|
||||||
client_backup_icinga_ca_source: "/var/lib/icinga2/ca/ca.crt"
|
client_backup_icinga_ca_source: "/var/lib/icinga2/ca/ca.crt"
|
||||||
|
|
||||||
# `ttl` du resultat passif : au-dela, Icinga perime le service de lui-meme — c'est ce qui
|
# `ttl` du resultat passif : au-dela, Icinga perime le service de lui-meme — c'est ce qui
|
||||||
|
|
@ -128,3 +169,27 @@ client_backup_restauration_part: "10%"
|
||||||
# Huit jours : une semaine entre deux controles, plus une journee de marge. Au-dela sans
|
# Huit jours : une semaine entre deux controles, plus une journee de marge. Au-dela sans
|
||||||
# nouvelle, Icinga passe le service au rouge (voir `serveur_icinga_fraicheur_restauration`).
|
# nouvelle, Icinga passe le service au rouge (voir `serveur_icinga_fraicheur_restauration`).
|
||||||
client_backup_ttl_restauration: 691200
|
client_backup_ttl_restauration: 691200
|
||||||
|
|
||||||
|
# Combien de temps un script attend un depot verrouille par un autre (`restic --retry-lock`)
|
||||||
|
# avant d'echouer. Le plus long des occupants est le controle de restauration ; il relit
|
||||||
|
# 10 % du depot et le restaure entier.
|
||||||
|
client_backup_attente_verrou: "30m"
|
||||||
|
|
||||||
|
# --- LA RESTAURATION DANS LA RECONSTRUCTION (2026-09-30) ----------------------------
|
||||||
|
#
|
||||||
|
# Jusqu'ici une reconstruction repartait d'un etat NEUF : les instantanes se restauraient
|
||||||
|
# pour PROUVER qu'ils s'ouvrent, jamais pour etre remis en service. Desormais chaque role
|
||||||
|
# proprietaire d'un etat inclut `tasks/restaurer.yml` au moment ou il le creerait neuf, et
|
||||||
|
# remet celui de l'incarnation precedente s'il existe. Voir `templates/restaurer.sh.j2`.
|
||||||
|
#
|
||||||
|
# `false` : ne jamais restaurer (chaque jeu est alors acte « desactive », et la
|
||||||
|
# sauvegarde ne s'en trouve pas bloquee).
|
||||||
|
client_backup_restauration_active: true
|
||||||
|
# Imposer un instantane (son `short_id`) au lieu du dernier anterieur a la naissance.
|
||||||
|
# Par hote, et le temps d'une passe : `-e client_backup_restauration_instantane=abcd1234`.
|
||||||
|
client_backup_restauration_instantane: ""
|
||||||
|
# Ou chaque jeu laisse la trace de ce qui lui est arrive (restaure, neuf, en_place...).
|
||||||
|
client_backup_restauration_marques: "/etc/setops/restauration"
|
||||||
|
# Ce qui etait en place avant d'etre ecrase. Hors des chemins sauvegardes : on ne veut
|
||||||
|
# pas deposer chez l'hebergeur l'etat qu'on vient justement de remplacer.
|
||||||
|
client_backup_restauration_mise_de_cote: "/var/backups/setops-avant-restauration"
|
||||||
|
|
|
||||||
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")"
|
||||||
432
roles/client_backup/files/setops-temoins.py
Normal file
432
roles/client_backup/files/setops-temoins.py
Normal file
|
|
@ -0,0 +1,432 @@
|
||||||
|
#!/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")
|
||||||
|
# LES MORCEAUX, PAS L'INDEX (2026-10-08). `chunks/index/` loge l'index TSDB et la base du
|
||||||
|
# compacteur : il les fusionne et les remplace. A la reconstruction de Technolibre, trois
|
||||||
|
# de ces fichiers « d'avant » manquaient, et le temoin les disait retires « (retention ?) »
|
||||||
|
# — aucun journal ne l'etait (Loki rendait ceux d'avant le rasage, minute par minute).
|
||||||
|
morceaux = lambda racine: {rel for rel in arbre(racine / "chunks") if not rel.startswith("index/")} # noqa: E731
|
||||||
|
a = morceaux(avant)
|
||||||
|
v = morceaux(vivant)
|
||||||
|
r.compte = len(a)
|
||||||
|
if a and not (a & v):
|
||||||
|
r.ecarts.append(f"aucun des {len(a)} morceau(x) de journal d'avant ne subsiste : journaux non restaures")
|
||||||
|
elif a - v:
|
||||||
|
r.retires.append(f"{len(a - v)} morceau(x) d'avant retire(s) (retention ?)")
|
||||||
|
if v - a:
|
||||||
|
r.nouveaux.append(f"{len(v - a)} morceau(x) nouveau(x)")
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
# --- L'annuaire ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def entrees_ldif(texte: str) -> dict[str, dict[str, list[str]]]:
|
||||||
|
"""{dn : {attribut : [valeurs triées]}}, attributs opérationnels exclus."""
|
||||||
|
deplie = re.sub(r"\n ", "", texte.replace("\r\n", "\n"))
|
||||||
|
out: dict[str, dict[str, list[str]]] = {}
|
||||||
|
for bloc in deplie.split("\n\n"):
|
||||||
|
attrs: dict[str, list[str]] = {}
|
||||||
|
dn = ""
|
||||||
|
for ligne in bloc.splitlines():
|
||||||
|
if not ligne or ligne.startswith("#") or ":" not in ligne:
|
||||||
|
continue
|
||||||
|
nom, _, val = ligne.partition(":")
|
||||||
|
if val.startswith(":"):
|
||||||
|
val = base64.b64decode(val[1:].strip()).decode("utf-8", "replace")
|
||||||
|
else:
|
||||||
|
val = val.strip()
|
||||||
|
if nom.lower() == "dn":
|
||||||
|
dn = val.lower()
|
||||||
|
elif nom.lower() not in OPERATIONNELS:
|
||||||
|
attrs.setdefault(nom.lower(), []).append(val)
|
||||||
|
if dn:
|
||||||
|
out[dn] = {k: sorted(v) for k, v in attrs.items()}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def jeu_annuaire(avant_ldif: Path, vivant_ldif: Path) -> Rapport:
|
||||||
|
r = Rapport("openldap")
|
||||||
|
if not avant_ldif.is_file():
|
||||||
|
r.ecarts.append(f"l'instantane n'emporte pas d'annuaire ({avant_ldif.name})")
|
||||||
|
return r
|
||||||
|
a = entrees_ldif(avant_ldif.read_text(errors="replace"))
|
||||||
|
v = entrees_ldif(vivant_ldif.read_text(errors="replace")) if vivant_ldif.is_file() else {}
|
||||||
|
r.compte = len(a)
|
||||||
|
for dn, attrs in sorted(a.items()):
|
||||||
|
if dn not in v:
|
||||||
|
r.ecarts.append(f"perdue : {dn}")
|
||||||
|
continue
|
||||||
|
if attrs == v[dn]:
|
||||||
|
continue
|
||||||
|
# Le mot de passe d'une entrée, c'est ce que la reconstruction du 2026-09-30 avait
|
||||||
|
# remis à la valeur d'amorçage sans que rien ne le dise.
|
||||||
|
if attrs.get("userpassword") != v[dn].get("userpassword"):
|
||||||
|
r.ecarts.append(f"IDENTITE userPassword : {dn}")
|
||||||
|
diff = sorted(k for k in set(attrs) | set(v[dn])
|
||||||
|
if k != "userpassword" and attrs.get(k) != v[dn].get(k))
|
||||||
|
if diff:
|
||||||
|
r.changes.append(f"{dn} ({', '.join(diff)})")
|
||||||
|
r.nouveaux.extend(sorted(set(v) - set(a)))
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
# --- PostgreSQL ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def lire_dump(texte: str) -> tuple[set[str], set[str], dict[tuple[str, str, str], list[str]]]:
|
||||||
|
"""(bases, rôles, {(base, table, 1re colonne) : [valeurs]}) d'après un `pg_dumpall`."""
|
||||||
|
bases = set(re.findall(r"^CREATE DATABASE (\S+) ", texte, re.M))
|
||||||
|
roles = set(re.findall(r"^CREATE ROLE (\S+);", texte, re.M))
|
||||||
|
cles: dict[tuple[str, str, str], list[str]] = {}
|
||||||
|
base, table = "", None
|
||||||
|
for ligne in texte.splitlines():
|
||||||
|
if table is not None:
|
||||||
|
if ligne == "\\.":
|
||||||
|
table = None
|
||||||
|
else:
|
||||||
|
cles[table].append(ligne.split("\t", 1)[0])
|
||||||
|
elif ligne.startswith("\\connect "):
|
||||||
|
base = ligne.split()[-1]
|
||||||
|
else:
|
||||||
|
m = re.match(r"^COPY (\S+) \(([^,)]+)[,)].*FROM stdin;$", ligne)
|
||||||
|
if m:
|
||||||
|
table = (base, m.group(1), m.group(2).strip())
|
||||||
|
cles[table] = []
|
||||||
|
return bases, roles, cles
|
||||||
|
|
||||||
|
|
||||||
|
def psql(base: str, sql: str) -> list[str] | None:
|
||||||
|
r = subprocess.run(["runuser", "-u", "postgres", "--", "psql", "-tAX", "-d", base, "-c", sql],
|
||||||
|
capture_output=True, text=True, env=dict(os.environ, PAGER="cat"))
|
||||||
|
return r.stdout.splitlines() if r.returncode == 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def jeu_postgresql(dump: Path, interroger=psql) -> Rapport:
|
||||||
|
"""Par CLÉ, pas par nombre de lignes (2026-10-07).
|
||||||
|
|
||||||
|
Compter ne voyait rien : l'historique d'Icinga DB, entièrement remplacé (412 lignes
|
||||||
|
d'avant, toutes perdues ; 380 neuves), passait pour « 412 -> 380 ». On compare donc la
|
||||||
|
première colonne de chaque table, lue au même format que le dump (`COPY ... TO STDOUT`).
|
||||||
|
|
||||||
|
Ce qui est un ÉCART, d'après la mesure sur Chezlepro (avant-raser contre vivant) :
|
||||||
|
- une base dont AUCUNE ligne d'avant ne subsiste, toutes tables confondues : elle n'a
|
||||||
|
pas été restaurée (c'était icingadb, ses 30 tables renouvelées) ;
|
||||||
|
- une ligne perdue dans une table d'HISTORIQUE (`*history*`) : par nature, on y
|
||||||
|
ajoute, on n'y retire pas.
|
||||||
|
Le reste est listé : la découverte de grappe de Keycloak (`jgroups_ping`), ses sessions,
|
||||||
|
une configuration de composant régénérée renouvellent leurs clés dans la vie ordinaire.
|
||||||
|
"""
|
||||||
|
r = Rapport("postgresql")
|
||||||
|
if not dump.is_file():
|
||||||
|
r.ecarts.append(f"l'instantane n'emporte pas de dump ({dump.name})")
|
||||||
|
return r
|
||||||
|
bases, roles, cles = lire_dump(dump.read_text(errors="replace"))
|
||||||
|
vivantes = set(interroger("postgres", "select datname from pg_database") or [])
|
||||||
|
roles_vivants = set(interroger("postgres", "select rolname from pg_roles") or [])
|
||||||
|
for b in sorted(bases - vivantes):
|
||||||
|
r.ecarts.append(f"base perdue : {b}")
|
||||||
|
for x in sorted(roles - roles_vivants):
|
||||||
|
r.ecarts.append(f"role perdu : {x}")
|
||||||
|
par_base: dict[str, list[int]] = collections.defaultdict(lambda: [0, 0])
|
||||||
|
for (b, t, col), avant in sorted(cles.items()):
|
||||||
|
if b not in vivantes or not avant:
|
||||||
|
continue
|
||||||
|
r.compte += 1
|
||||||
|
vivant = interroger(b, f"COPY (select {col} from {t}) TO STDOUT")
|
||||||
|
if vivant is None:
|
||||||
|
r.ecarts.append(f"table perdue : {b}.{t}")
|
||||||
|
continue
|
||||||
|
a, v = set(avant), set(vivant)
|
||||||
|
communes = len(a & v)
|
||||||
|
par_base[b][0] += len(a)
|
||||||
|
par_base[b][1] += communes
|
||||||
|
perdues, nouvelles = len(a) - communes, len(v - a)
|
||||||
|
if perdues and "history" in t:
|
||||||
|
r.ecarts.append(f"historique perdu : {b}.{t} : {perdues} ligne(s) d'avant sur {len(a)}")
|
||||||
|
elif perdues or nouvelles:
|
||||||
|
r.changes.append(f"{b}.{t} : {perdues} retiree(s), {nouvelles} nouvelle(s) (avant {len(a)})")
|
||||||
|
for b, (n, communes) in sorted(par_base.items()):
|
||||||
|
if n and not communes:
|
||||||
|
r.ecarts.insert(0, f"base {b} : aucune des {n} ligne(s) d'avant ne subsiste — non restauree")
|
||||||
|
# `template0` n'est jamais dans un `pg_dumpall` : il n'est pas « nouveau », il est hors jeu.
|
||||||
|
r.nouveaux.extend(f"base {b}" for b in sorted(vivantes - bases - {"postgres", "template0"}))
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
# --- Programme -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None, interroger=psql) -> int:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
ap.add_argument("--avant", required=True, type=Path, help="racine où l'instantané est extrait")
|
||||||
|
ap.add_argument("--instantane", required=True)
|
||||||
|
ap.add_argument("--heure", default="")
|
||||||
|
ap.add_argument("--hote", default="")
|
||||||
|
ap.add_argument("--jeu", action="append", default=[], help="NOM=CHEMIN[:CHEMIN...]")
|
||||||
|
ap.add_argument("--vivant", action="append", default=[],
|
||||||
|
help="NOM=CHEMIN : ou vit la base d'un jeu copie a froid")
|
||||||
|
ap.add_argument("--ldif", default="", help="chemin du LDIF dans l'instantané")
|
||||||
|
ap.add_argument("--ldif-vivant", type=Path, default=None)
|
||||||
|
ap.add_argument("--dump", default="", help="chemin du pg_dumpall dans l'instantané")
|
||||||
|
a = ap.parse_args(argv)
|
||||||
|
|
||||||
|
print(f"TEMOINS de {a.hote} : instantane {a.instantane} ({a.heure}) contre l'etat vivant")
|
||||||
|
if not a.jeu:
|
||||||
|
print(" SANS OBJET : ce noeud ne detient aucun etat.")
|
||||||
|
return 0
|
||||||
|
ecart = False
|
||||||
|
vivants = dict(v.partition("=")[::2] for v in a.vivant)
|
||||||
|
for spec in a.jeu:
|
||||||
|
nom, _, chemins = spec.partition("=")
|
||||||
|
# La copie a froid est le SEUL sous-dossier de la preparation du jeu.
|
||||||
|
copie = lambda: next((d for d in sorted((a.avant / chemins.split(":")[0].lstrip("/")).glob("*")) # noqa: E731
|
||||||
|
if d.is_dir()), a.avant / "absent")
|
||||||
|
if nom == "prometheus" and nom in vivants:
|
||||||
|
r, unite = jeu_prometheus(copie(), Path(vivants[nom])), "bloc(s)"
|
||||||
|
elif nom == "loki" and nom in vivants:
|
||||||
|
r, unite = jeu_loki(copie(), Path(vivants[nom])), "morceau(x)"
|
||||||
|
elif nom == "openldap":
|
||||||
|
r, unite = jeu_annuaire(a.avant / a.ldif.lstrip("/"), a.ldif_vivant or Path("/nonexistent")), "entree(s)"
|
||||||
|
elif nom == "postgresql":
|
||||||
|
r, unite = jeu_postgresql(a.avant / a.dump.lstrip("/"), interroger), "table(s)"
|
||||||
|
else:
|
||||||
|
r, unite = jeu_fichiers(nom, [c for c in chemins.split(":") if c], a.avant), "fichier(s)"
|
||||||
|
print("\n".join(r.lignes(unite)))
|
||||||
|
ecart = ecart or bool(r.ecarts)
|
||||||
|
print("ECART : une perte, ou une identite qui a change." if ecart
|
||||||
|
else "Aucun ecart : rien de perdu, l'identite est intacte.")
|
||||||
|
return 1 if ecart else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
|
@ -2,3 +2,36 @@
|
||||||
- name: Recharger systemd
|
- name: Recharger systemd
|
||||||
ansible.builtin.systemd:
|
ansible.builtin.systemd:
|
||||||
daemon_reload: true
|
daemon_reload: true
|
||||||
|
|
||||||
|
# PREMIER RAPPORT A UN ICINGA QUI NE NOUS A JAMAIS ENTENDUS — voir `tasks/verifier.yml`.
|
||||||
|
# Trois temps, puis la trace, dans cet ordre (les gestionnaires s'executent dans l'ordre de ce fichier) :
|
||||||
|
# deposer, pour que le depot porte un instantane de CETTE incarnation ; verifier le depot ;
|
||||||
|
# verifier qu'il se restaure. Les unites sont `oneshot` : chacune rend la main une fois son
|
||||||
|
# rapport parti, et echoue — bruyamment — si la sauvegarde refuse (etat d'avant non remis)
|
||||||
|
# ou si Icinga ne prend pas le resultat.
|
||||||
|
- name: Premier depot
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: setops-sauvegarde.service
|
||||||
|
state: started
|
||||||
|
listen: Premier rapport a Icinga
|
||||||
|
|
||||||
|
- name: Premier controle du depot
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: setops-verification-depot.service
|
||||||
|
state: started
|
||||||
|
listen: Premier rapport a Icinga
|
||||||
|
|
||||||
|
- name: Premier controle de restauration
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: setops-verification-restauration.service
|
||||||
|
state: started
|
||||||
|
listen: Premier rapport a Icinga
|
||||||
|
|
||||||
|
- name: Retenir l'Icinga qui nous a entendus
|
||||||
|
ansible.builtin.copy:
|
||||||
|
content: "{{ client_backup_icinga_empreinte }}\n"
|
||||||
|
dest: "{{ client_backup_marque_icinga }}"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0600"
|
||||||
|
listen: Premier rapport a Icinga
|
||||||
|
|
|
||||||
78
roles/client_backup/tasks/acces.yml
Normal file
78
roles/client_backup/tasks/acces.yml
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
---
|
||||||
|
# ACCES AU DEPOT DE CE NOEUD — partage par la sauvegarde (`main.yml`) et par la
|
||||||
|
# restauration (`restaurer.yml`, incluse par les roles proprietaires de l'etat).
|
||||||
|
#
|
||||||
|
# Une seule copie de ces taches : deux facons de poser une cle finiraient par diverger,
|
||||||
|
# et on ne le decouvrirait que le jour ou l'on restaure.
|
||||||
|
- name: Installer restic
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: "{{ client_backup_paquets }}"
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
|
||||||
|
- name: Créer le répertoire des secrets Set-OPS
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /etc/setops
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0700"
|
||||||
|
|
||||||
|
- name: Déposer la clé SSH privée de sauvegarde
|
||||||
|
ansible.builtin.copy:
|
||||||
|
content: "{{ client_backup_ssh_privkey }}"
|
||||||
|
dest: "{{ client_backup_ssh_key }}"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0600"
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Déposer le mot de passe restic
|
||||||
|
ansible.builtin.copy:
|
||||||
|
content: "{{ client_backup_password }}\n"
|
||||||
|
dest: /etc/setops/restic.pass
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0600"
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Assurer /root/.ssh
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /root/.ssh
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0700"
|
||||||
|
|
||||||
|
- name: Configurer l'accès SSH à la cible de sauvegarde
|
||||||
|
ansible.builtin.blockinfile:
|
||||||
|
path: /root/.ssh/config
|
||||||
|
create: true
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0600"
|
||||||
|
marker: "# {mark} SETOPS BACKUP {{ client_backup_cible }}"
|
||||||
|
block: |
|
||||||
|
Host {{ client_backup_cible }}
|
||||||
|
User {{ client_backup_utilisateur_distant }}
|
||||||
|
IdentityFile {{ client_backup_ssh_key }}
|
||||||
|
IdentitiesOnly yes
|
||||||
|
StrictHostKeyChecking accept-new
|
||||||
|
|
||||||
|
- name: Déployer l'outil de restauration (setops-restaurer)
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: restaurer.sh.j2
|
||||||
|
dest: /usr/local/sbin/setops-restaurer
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0700"
|
||||||
|
|
||||||
|
# LES TEMOINS (2026-10-07) : `setops-restaurer temoins` extrait l'instantane d'avant rasage,
|
||||||
|
# puis ce comparateur le confronte a l'etat vivant. Lecture seule.
|
||||||
|
- name: Déployer le comparateur des témoins (setops-temoins)
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: setops-temoins.py
|
||||||
|
dest: /usr/local/sbin/setops-temoins
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0700"
|
||||||
31
roles/client_backup/tasks/acter.yml
Normal file
31
roles/client_backup/tasks/acter.yml
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
---
|
||||||
|
# POSER LE MARQUEUR D'UN JEU — APRES le geste, jamais avant : ecrit d'abord, une
|
||||||
|
# restauration ratee laisserait le noeud convaincu d'avoir remis un etat qu'il n'a pas,
|
||||||
|
# et la sauvegarde recommencerait a deposer par-dessus.
|
||||||
|
#
|
||||||
|
# entree : client_backup_acter_etat (restaure, neuf, en_place, desactive, abandonne)
|
||||||
|
# client_backup_restauration (le jeu, et l'instantane s'il y en a un)
|
||||||
|
- name: Restauration — repertoire des marqueurs
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ client_backup_restauration_marques }}"
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0700"
|
||||||
|
when: not ansible_check_mode
|
||||||
|
|
||||||
|
- name: Restauration — acter le jeu
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "{{ client_backup_restauration_marques }}/{{ client_backup_restauration.jeu }}"
|
||||||
|
content: |
|
||||||
|
etat={{ client_backup_acter_etat }}
|
||||||
|
instantane={{ client_backup_restauration.instantane | default('') }}
|
||||||
|
date={{ now(utc=true).isoformat() }}
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0600"
|
||||||
|
when: not ansible_check_mode
|
||||||
|
|
||||||
|
- name: Restauration — retenir l'etat acte
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
client_backup_restauration: "{{ client_backup_restauration | combine({'etat': client_backup_acter_etat}) }}"
|
||||||
|
|
@ -34,60 +34,10 @@
|
||||||
+ (client_backup_jobs | map(attribute='nom') | join(', ')),
|
+ (client_backup_jobs | map(attribute='nom') | join(', ')),
|
||||||
'aucun etat non regenerable — aucune sauvegarde installee') }}
|
'aucun etat non regenerable — aucune sauvegarde installee') }}
|
||||||
|
|
||||||
- name: Installer restic
|
# L'ACCES AU DEPOT vit a part : les roles proprietaires l'incluent aussi, pour RESTAURER
|
||||||
ansible.builtin.apt:
|
# avant meme que ce role-ci ait tourne (l'AC se restaure dans la couche `pki_racine`).
|
||||||
name: "{{ client_backup_paquets }}"
|
- name: Accès au dépôt (restic, clé, mot de passe, SSH) et outil de restauration
|
||||||
state: present
|
ansible.builtin.include_tasks: acces.yml
|
||||||
update_cache: true
|
|
||||||
|
|
||||||
- name: Créer le répertoire des secrets Set-OPS
|
|
||||||
ansible.builtin.file:
|
|
||||||
path: /etc/setops
|
|
||||||
state: directory
|
|
||||||
owner: root
|
|
||||||
group: root
|
|
||||||
mode: "0700"
|
|
||||||
|
|
||||||
- name: Déposer la clé SSH privée de sauvegarde
|
|
||||||
ansible.builtin.copy:
|
|
||||||
content: "{{ client_backup_ssh_privkey }}"
|
|
||||||
dest: "{{ client_backup_ssh_key }}"
|
|
||||||
owner: root
|
|
||||||
group: root
|
|
||||||
mode: "0600"
|
|
||||||
no_log: true
|
|
||||||
|
|
||||||
- name: Déposer le mot de passe restic
|
|
||||||
ansible.builtin.copy:
|
|
||||||
content: "{{ client_backup_password }}\n"
|
|
||||||
dest: /etc/setops/restic.pass
|
|
||||||
owner: root
|
|
||||||
group: root
|
|
||||||
mode: "0600"
|
|
||||||
no_log: true
|
|
||||||
|
|
||||||
- name: Assurer /root/.ssh
|
|
||||||
ansible.builtin.file:
|
|
||||||
path: /root/.ssh
|
|
||||||
state: directory
|
|
||||||
owner: root
|
|
||||||
group: root
|
|
||||||
mode: "0700"
|
|
||||||
|
|
||||||
- name: Configurer l'accès SSH à la cible de sauvegarde
|
|
||||||
ansible.builtin.blockinfile:
|
|
||||||
path: /root/.ssh/config
|
|
||||||
create: true
|
|
||||||
owner: root
|
|
||||||
group: root
|
|
||||||
mode: "0600"
|
|
||||||
marker: "# {mark} SETOPS BACKUP {{ client_backup_cible }}"
|
|
||||||
block: |
|
|
||||||
Host {{ client_backup_cible }}
|
|
||||||
User {{ client_backup_utilisateur_distant }}
|
|
||||||
IdentityFile {{ client_backup_ssh_key }}
|
|
||||||
IdentitiesOnly yes
|
|
||||||
StrictHostKeyChecking accept-new
|
|
||||||
|
|
||||||
- name: Assurer le répertoire de préparation des dumps
|
- name: Assurer le répertoire de préparation des dumps
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
|
|
@ -106,6 +56,17 @@
|
||||||
group: root
|
group: root
|
||||||
mode: "0700"
|
mode: "0700"
|
||||||
|
|
||||||
|
# LA COPIE A FROID (2026-10-07) : les jeux qui s'ecrivent en continu (Prometheus, Loki)
|
||||||
|
# arretent leur service le temps de copier. L'outil est deploye avec le script qui l'appelle.
|
||||||
|
- name: Déployer l'outil de copie à froid
|
||||||
|
when: client_backup_jobs | length > 0
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: setops-copie-a-froid.sh
|
||||||
|
dest: /usr/local/sbin/setops-copie-a-froid
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0700"
|
||||||
|
|
||||||
- name: Déployer l'unité et le timer systemd
|
- name: Déployer l'unité et le timer systemd
|
||||||
when: client_backup_jobs | length > 0
|
when: client_backup_jobs | length > 0
|
||||||
ansible.builtin.template:
|
ansible.builtin.template:
|
||||||
|
|
@ -117,6 +78,7 @@
|
||||||
loop:
|
loop:
|
||||||
- { s: setops-sauvegarde.service.j2, d: setops-sauvegarde.service }
|
- { s: setops-sauvegarde.service.j2, d: setops-sauvegarde.service }
|
||||||
- { s: setops-sauvegarde.timer.j2, d: setops-sauvegarde.timer }
|
- { s: setops-sauvegarde.timer.j2, d: setops-sauvegarde.timer }
|
||||||
|
- { s: setops-sauvegarde-avant-raser.service.j2, d: setops-sauvegarde-avant-raser.service }
|
||||||
|
|
||||||
- name: Activer le timer de sauvegarde
|
- name: Activer le timer de sauvegarde
|
||||||
when:
|
when:
|
||||||
|
|
@ -140,6 +102,24 @@
|
||||||
state: stopped
|
state: stopped
|
||||||
failed_when: false
|
failed_when: false
|
||||||
|
|
||||||
|
# LES VERIFICATIONS PARTENT AVEC LA SAUVEGARDE (2026-09-30). Retirees seules, les unites de
|
||||||
|
# sauvegarde laissaient derriere elles celles qui VERIFIENT le depot et la restauration :
|
||||||
|
# elles continuaient de rapporter a des services qu'Icinga ne declare plus (le noeud n'est
|
||||||
|
# plus detenteur d'etat) — un 404 toutes les quatre heures, et une unite rouge. Vu en
|
||||||
|
# retirant le web frontal du catalogue.
|
||||||
|
- name: Arreter les verifications d'une sauvegarde devenue vide
|
||||||
|
when:
|
||||||
|
- client_backup_jobs | length == 0
|
||||||
|
- not ansible_check_mode
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: "{{ item }}"
|
||||||
|
enabled: false
|
||||||
|
state: stopped
|
||||||
|
loop:
|
||||||
|
- setops-verification-depot.timer
|
||||||
|
- setops-verification-restauration.timer
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
- name: Retirer le script et les unites d'une sauvegarde vide
|
- name: Retirer le script et les unites d'une sauvegarde vide
|
||||||
when: client_backup_jobs | length == 0
|
when: client_backup_jobs | length == 0
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
|
|
@ -147,8 +127,16 @@
|
||||||
state: absent
|
state: absent
|
||||||
loop:
|
loop:
|
||||||
- /usr/local/sbin/setops-sauvegarder.sh
|
- /usr/local/sbin/setops-sauvegarder.sh
|
||||||
|
- /usr/local/sbin/setops-copie-a-froid
|
||||||
- /etc/systemd/system/setops-sauvegarde.service
|
- /etc/systemd/system/setops-sauvegarde.service
|
||||||
- /etc/systemd/system/setops-sauvegarde.timer
|
- /etc/systemd/system/setops-sauvegarde.timer
|
||||||
|
- /etc/systemd/system/setops-sauvegarde-avant-raser.service
|
||||||
|
- /usr/local/sbin/setops-verifier-mon-depot.sh
|
||||||
|
- /usr/local/sbin/setops-verifier-restauration.sh
|
||||||
|
- /etc/systemd/system/setops-verification-depot.service
|
||||||
|
- /etc/systemd/system/setops-verification-depot.timer
|
||||||
|
- /etc/systemd/system/setops-verification-restauration.service
|
||||||
|
- /etc/systemd/system/setops-verification-restauration.timer
|
||||||
notify: Recharger systemd
|
notify: Recharger systemd
|
||||||
|
|
||||||
# --- LE NOEUD VERIFIE SON PROPRE DEPOT ----------------------------------------
|
# --- LE NOEUD VERIFIE SON PROPRE DEPOT ----------------------------------------
|
||||||
|
|
|
||||||
98
roles/client_backup/tasks/restaurer.yml
Normal file
98
roles/client_backup/tasks/restaurer.yml
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
---
|
||||||
|
# L'ETAT D'UNE INCARNATION PRECEDENTE ATTEND-IL CE JEU ?
|
||||||
|
#
|
||||||
|
# Inclus par le role PROPRIETAIRE de l'etat, au moment precis ou il le creerait neuf :
|
||||||
|
# l'AC avant `step ca init`, les bases juste creees, l'annuaire une fois son schema pose...
|
||||||
|
# C'est lui qui sait quand son etat est « vierge » ; ce fichier sait ou est le depot.
|
||||||
|
#
|
||||||
|
# entree : client_backup_restaurer_jeu — groupe du catalogue (ex. serveur_step_ca)
|
||||||
|
# client_backup_restaurer_vierge — le role n'a encore RIEN de cet etat
|
||||||
|
# sortie : client_backup_restauration — {jeu, etat, instantane, chemins}
|
||||||
|
#
|
||||||
|
# etat : `a_restaurer` (au role de le remettre, puis d'inclure `acter.yml`), `acte`
|
||||||
|
# (deja tranche a une passe precedente), `en_place` (un etat existe : on ne l'ecrase
|
||||||
|
# jamais d'office), `neuf` (rien d'anterieur), `sans_objet`, `simulation`.
|
||||||
|
- name: Restauration — le jeu et ce qu'il emporte
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
client_backup_restauration:
|
||||||
|
jeu: "{{ client_backup_catalogue[client_backup_restaurer_jeu].nom }}"
|
||||||
|
chemins: "{{ client_backup_catalogue[client_backup_restaurer_jeu].chemins }}"
|
||||||
|
etat: >-
|
||||||
|
{{ 'simulation' if ansible_check_mode
|
||||||
|
else ('sans_objet' if ('client_backup' not in group_names)
|
||||||
|
else ('desactive' if not (client_backup_restauration_active | bool)
|
||||||
|
else 'a_decider')) }}
|
||||||
|
instantane: ""
|
||||||
|
|
||||||
|
- name: Restauration — trancher pour ce jeu
|
||||||
|
when: client_backup_restauration.etat != 'sans_objet' and client_backup_restauration.etat != 'simulation'
|
||||||
|
block:
|
||||||
|
- name: Restauration — ce jeu a-t-il deja ete tranche ?
|
||||||
|
ansible.builtin.slurp:
|
||||||
|
src: "{{ client_backup_restauration_marques }}/{{ client_backup_restauration.jeu }}"
|
||||||
|
register: client_backup_marque_lue
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Restauration — deja tranche a une passe precedente
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
client_backup_restauration: >-
|
||||||
|
{{ client_backup_restauration | combine({'etat': 'acte',
|
||||||
|
'instantane': ((client_backup_marque_lue.content | b64decode).splitlines()
|
||||||
|
| select('match', '^instantane=') | first
|
||||||
|
| default('instantane=')).split('=', 1)[1]}) }}
|
||||||
|
when: client_backup_marque_lue.content is defined
|
||||||
|
|
||||||
|
- name: "Restauration — desactivee, ou un etat existe deja, l'acter tel quel"
|
||||||
|
ansible.builtin.include_tasks: acter.yml
|
||||||
|
vars:
|
||||||
|
client_backup_acter_etat: "{{ 'desactive' if client_backup_restauration.etat == 'desactive' else 'en_place' }}"
|
||||||
|
when:
|
||||||
|
- client_backup_marque_lue.content is not defined
|
||||||
|
- client_backup_restauration.etat == 'desactive' or not (client_backup_restaurer_vierge | bool)
|
||||||
|
|
||||||
|
# LA CONDITION D'UN BLOC SE REEVALUE A CHAQUE TACHE. Ecrite sur `etat`, elle se
|
||||||
|
# fermerait des que le choix le change — et « rien d'anterieur » ne serait jamais acte.
|
||||||
|
# On la fige donc avant d'entrer.
|
||||||
|
- name: Restauration — faut-il interroger le depot ?
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
client_backup_interroger: >-
|
||||||
|
{{ client_backup_marque_lue.content is not defined
|
||||||
|
and client_backup_restauration.etat == 'a_decider'
|
||||||
|
and (client_backup_restaurer_vierge | bool) }}
|
||||||
|
|
||||||
|
- name: Restauration — interroger le depot
|
||||||
|
when: client_backup_interroger | bool
|
||||||
|
block:
|
||||||
|
- name: Restauration — acces au depot (le role client_backup n'a peut-etre pas encore tourne)
|
||||||
|
ansible.builtin.include_tasks: acces.yml
|
||||||
|
|
||||||
|
# Code 2 = depot ILLISIBLE : on echoue. Conclure « rien a restaurer » faute de
|
||||||
|
# joindre le depot ferait naitre neuf ce qui attend chez l'hebergeur.
|
||||||
|
- name: Restauration — l'instantane de l'incarnation precedente
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv: >-
|
||||||
|
{{ ['/usr/local/sbin/setops-restaurer', 'choisir']
|
||||||
|
+ (['--instantane', client_backup_restauration_instantane]
|
||||||
|
if client_backup_restauration_instantane | length > 0 else []) }}
|
||||||
|
register: client_backup_choix
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Restauration — retenir le choix
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
client_backup_restauration: >-
|
||||||
|
{{ client_backup_restauration | combine({
|
||||||
|
'etat': ('a_restaurer' if (client_backup_choix.stdout | from_json).instantane else 'neuf'),
|
||||||
|
'instantane': (client_backup_choix.stdout | from_json).instantane}) }}
|
||||||
|
|
||||||
|
- name: "Restauration — rien d'anterieur, naissance"
|
||||||
|
ansible.builtin.include_tasks: acter.yml
|
||||||
|
vars:
|
||||||
|
client_backup_acter_etat: neuf
|
||||||
|
when: client_backup_restauration.etat == 'neuf'
|
||||||
|
|
||||||
|
- name: Restauration — verdict
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: >-
|
||||||
|
{{ client_backup_restauration.jeu }} : {{ client_backup_restauration.etat }}{{
|
||||||
|
(' — instantane ' + client_backup_restauration.instantane)
|
||||||
|
if client_backup_restauration.instantane | length > 0 else '' }}
|
||||||
|
|
@ -46,6 +46,45 @@
|
||||||
group: root
|
group: root
|
||||||
mode: "0644"
|
mode: "0644"
|
||||||
|
|
||||||
|
# UN ICINGA QUI NE NOUS A JAMAIS ENTENDUS (2026-09-30). Ses services `sauvegarde` et
|
||||||
|
# `restauration` sont CRITIQUES tant qu'aucun rapport n'est arrive — c'est voulu, le silence
|
||||||
|
# doit alerter. Mais les minuteurs ne rapportent que toutes les 4 h (depot) et le DIMANCHE
|
||||||
|
# (restauration) : apres chaque reconstruction, Icinga restait rouge jusqu'a une semaine.
|
||||||
|
#
|
||||||
|
# Le noeud ne peut pas demander a Icinga s'il l'a deja entendu : son compte ne sait que
|
||||||
|
# DEPOSER. Il retient donc LUI-MEME a quel Icinga il a rapporte — l'empreinte de son AC. Un
|
||||||
|
# Icinga refait a une AC neuve ; un noeud refait n'a pas de marqueur.
|
||||||
|
#
|
||||||
|
# UN MARQUEUR A LUI, PAS LA COPIE DE L'AC. La premiere version se fiait au changement de
|
||||||
|
# `icinga-ca.crt` — or `client_sante` depose le MEME fichier, plus tot dans le deploiement :
|
||||||
|
# sur une flotte neuve il etait deja la, « ok », et rien n'etait notifie. Vu a la
|
||||||
|
# reconstruction de Chezlepro du 2026-09-30 : `restauration` rouge sur sept noeuds, et le
|
||||||
|
# pare-feu arrete. L'epreuve precedente avait joue `client_backup` SEUL, et ne pouvait pas
|
||||||
|
# le voir.
|
||||||
|
#
|
||||||
|
# ECRIT APRES LE RAPPORT (dernier gestionnaire), jamais avant : un rapport rate laisserait
|
||||||
|
# sinon le noeud convaincu d'avoir ete entendu, et la passe suivante ne retenterait rien.
|
||||||
|
- name: Retenir l'empreinte de l'Icinga destinataire
|
||||||
|
when: client_backup_ca_presente.stat.exists
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
client_backup_icinga_empreinte: "{{ client_backup_ca_icinga.content | b64decode | hash('sha256') }}"
|
||||||
|
|
||||||
|
- name: A quel Icinga ce noeud a-t-il deja rapporte ?
|
||||||
|
when: client_backup_ca_presente.stat.exists
|
||||||
|
ansible.builtin.slurp:
|
||||||
|
src: "{{ client_backup_marque_icinga }}"
|
||||||
|
register: client_backup_icinga_entendu
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Cet Icinga ne nous a jamais entendus — premier rapport
|
||||||
|
when:
|
||||||
|
- client_backup_ca_presente.stat.exists
|
||||||
|
- (client_backup_icinga_entendu.content | default('') | b64decode | trim) != client_backup_icinga_empreinte
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: "Icinga {{ client_backup_icinga_empreinte[:12] }} n'a encore rien recu de {{ inventory_hostname }} : rapport immediat."
|
||||||
|
changed_when: true
|
||||||
|
notify: Premier rapport a Icinga
|
||||||
|
|
||||||
- name: Installer curl pour le rapport passif
|
- name: Installer curl pour le rapport passif
|
||||||
ansible.builtin.apt:
|
ansible.builtin.apt:
|
||||||
name: curl
|
name: curl
|
||||||
|
|
|
||||||
417
roles/client_backup/templates/restaurer.sh.j2
Normal file
417
roles/client_backup/templates/restaurer.sh.j2
Normal file
|
|
@ -0,0 +1,417 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# GENERE par Set-OPS (role client_backup). Ne pas editer a la main.
|
||||||
|
#
|
||||||
|
# setops-restaurer — REMETTRE EN SERVICE l'etat d'une incarnation precedente de CE noeud.
|
||||||
|
#
|
||||||
|
# POURQUOI IL EXISTE (2026-09-30). Technolibre a ete rasee et reconstruite par ses
|
||||||
|
# runners : 0 echec, `make valider` vert, « restauration verifiee ». Et le mot de passe de
|
||||||
|
# `sysadmin` etait revenu a celui de l'amorcage. RIEN n'etait revenu : ni l'annuaire, ni
|
||||||
|
# la racine de l'AC, ni les bases, ni les fichiers de Nextcloud. « Restauration verifiee »
|
||||||
|
# voulait dire : restauree dans un repertoire temporaire, lue, jetee. Chaque
|
||||||
|
# reconstruction de Chezlepro etait repartie a vide de la meme facon.
|
||||||
|
#
|
||||||
|
# QUELLE INCARNATION. La cle d'hote SSH nait au clonage (`machine-id`, lui, vient du
|
||||||
|
# gabarit : tous les noeuds portent la meme date). L'instantane a remettre est donc le
|
||||||
|
# DERNIER PRIS AVANT LA NAISSANCE de cette cle — celui de la machine qu'on a rasee.
|
||||||
|
# `--instantane ID` impose un autre choix.
|
||||||
|
#
|
||||||
|
# LE MARQUEUR. Un jeu restaure, abandonne, ou trouve deja en place laisse une trace dans
|
||||||
|
# {{ client_backup_restauration_marques }}/<jeu>. La sauvegarde s'y fie (`garde`) : tant
|
||||||
|
# qu'un etat anterieur n'a ete ni remis ni ecarte, ce noeud REFUSE de deposer — sans quoi
|
||||||
|
# `restic forget --keep-daily` chasserait l'instantane d'avant au profit de l'etat neuf du
|
||||||
|
# meme jour.
|
||||||
|
#
|
||||||
|
# RIEN N'EST ECRASE SANS ETRE MIS DE COTE, dans {{ client_backup_restauration_mise_de_cote }}.
|
||||||
|
#
|
||||||
|
# Sous-commandes :
|
||||||
|
# etat ce qui serait restaure, et les marqueurs
|
||||||
|
# choisir [--instantane ID] (pour Ansible) JSON de l'instantane candidat
|
||||||
|
# fichiers [opts] CHEMIN... remettre des repertoires (rsync --delete)
|
||||||
|
# --proprietaire U:G --remplacer --vers DOSSIER (repetition, rien n'est touche)
|
||||||
|
# --depuis CHEMIN_DANS_L_INSTANTANE (un seul CHEMIN) : ce qui a ete sauvegarde
|
||||||
|
# ailleurs que la ou ca vit (copie a froid)
|
||||||
|
# annuaire [opts] BASE_DN remplacer la base LDAP (slapadd)
|
||||||
|
# --essai (slapadd -u a blanc, rien n'est touche)
|
||||||
|
# base [opts] NOM rejouer une base depuis le pg_dumpall
|
||||||
|
# --remplacer --vers AUTRE_NOM (repetition dans une base jetable)
|
||||||
|
# acter JEU ETAT [INSTANTANE] poser le marqueur (restaure, neuf, en_place, abandonne)
|
||||||
|
# garde (pour la sauvegarde) refuser si un etat attend
|
||||||
|
# temoins [--candidat | --instantane ID] l'etat vivant contre l'instantane d'avant rasage
|
||||||
|
# (par defaut le dernier etiquete « avant-raser ») ; lecture seule ; code 1 = ecart,
|
||||||
|
# 4 = rien a comparer
|
||||||
|
# Options communes : --instantane ID.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
export RESTIC_REPOSITORY="{{ client_backup_repo }}"
|
||||||
|
export RESTIC_PASSWORD_FILE="/etc/setops/restic.pass"
|
||||||
|
# UN VERROU N'EST PAS UNE PANNE (2026-10-04). La sauvegarde, la verification du depot, le
|
||||||
|
# controle de restauration et `setops-restaurer` partagent ce depot ; leurs minuteurs peuvent
|
||||||
|
# partir ensemble (`Persistent=true` rattrape au demarrage tout ce qui a ete manque). Sans
|
||||||
|
# attente, restic refuse aussitot un depot verrouille, et une sonde concluait « aucun
|
||||||
|
# instantane » ou « depot corrompu ». On attend le verrou au lieu de conclure.
|
||||||
|
restic() { command restic --retry-lock {{ client_backup_attente_verrou }} "$@"; }
|
||||||
|
MOI="{{ inventory_hostname }}"
|
||||||
|
MARQUES="{{ client_backup_restauration_marques }}"
|
||||||
|
MISE_DE_COTE="{{ client_backup_restauration_mise_de_cote }}"
|
||||||
|
CLE_HOTE="/etc/ssh/ssh_host_ed25519_key.pub"
|
||||||
|
LDIF="{{ client_backup_staging }}/openldap/annuaire.ldif"
|
||||||
|
DUMP_PG="{{ client_backup_staging }}/postgresql/toutes-bases.sql"
|
||||||
|
JEUX=({% for j in client_backup_jobs %} "{{ j.nom }}"{% endfor %} )
|
||||||
|
# Les chemins de chaque jeu, separes par « : » — ce que les temoins extraient et comparent.
|
||||||
|
declare -A CHEMINS_JEU=({% for j in client_backup_jobs %} ["{{ j.nom }}"]="{{ j.chemins | join(':') }}"{% endfor %} )
|
||||||
|
ETIQUETTE_AVANT_RASER="{{ client_backup_etiquette_avant_raser }}"
|
||||||
|
# Ou vit la base des jeux copies a froid (Prometheus, Loki) : leur instantane porte la
|
||||||
|
# copie, pas la base. Les temoins comparent l'une a l'autre.
|
||||||
|
declare -A VIVANT_JEU=({% for j in client_backup_jobs if j.vivant is defined %} ["{{ j.nom }}"]="{{ j.vivant }}"{% endfor %} )
|
||||||
|
|
||||||
|
dire() { printf '%s\n' "$*" >&2; }
|
||||||
|
mourir() { dire "setops-restaurer: $*"; exit 1; }
|
||||||
|
|
||||||
|
naissance() {
|
||||||
|
local b
|
||||||
|
b=$(stat -c %W "${CLE_HOTE}")
|
||||||
|
if [[ "${b}" -le 0 ]]; then b=$(stat -c %Y "${CLE_HOTE}"); fi
|
||||||
|
printf '%s\n' "${b}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Imprime « ID HEURE » du candidat, ou rien. Code 2 : depot illisible — et c'est une
|
||||||
|
# ERREUR, pas une absence : conclure « rien a restaurer » parce que le dépôt ne repond pas
|
||||||
|
# ferait marquer « neuf » une machine dont l'etat attend chez l'hebergeur.
|
||||||
|
candidat() {
|
||||||
|
local impose="${1:-}" json rc
|
||||||
|
set +e
|
||||||
|
json=$(restic snapshots --host "${MOI}" --json 2>/dev/null)
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
# restic 0.17+ : 10 = le depot n'existe pas. Premiere vie de l'ecosysteme.
|
||||||
|
if (( rc == 10 )); then return 0; fi
|
||||||
|
if (( rc != 0 )); then dire "depot illisible (restic rc=${rc}) : ${RESTIC_REPOSITORY}"; return 2; fi
|
||||||
|
printf '%s' "${json}" | python3 -c '
|
||||||
|
import datetime, json, re, sys
|
||||||
|
naissance, impose = int(sys.argv[1]), sys.argv[2]
|
||||||
|
def t(s):
|
||||||
|
s = re.sub(r"(\.\d{6})\d+", r"\1", s)
|
||||||
|
return datetime.datetime.fromisoformat(re.sub(r"Z$", "+00:00", s)).timestamp()
|
||||||
|
snaps = json.load(sys.stdin) or []
|
||||||
|
if impose:
|
||||||
|
c = [x for x in snaps if impose in (x["short_id"], x["id"])]
|
||||||
|
if not c:
|
||||||
|
sys.exit("instantane impose introuvable pour cet hote : " + impose)
|
||||||
|
else:
|
||||||
|
c = [x for x in snaps if t(x["time"]) < naissance]
|
||||||
|
c.sort(key=lambda x: t(x["time"]))
|
||||||
|
if c:
|
||||||
|
print(c[-1]["short_id"], c[-1]["time"][:19])
|
||||||
|
' "$(naissance)" "${impose}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# « ID HEURE » du dernier instantane etiquete avant rasage, ou rien ; code 2 = depot illisible.
|
||||||
|
avant_raser() {
|
||||||
|
local json rc
|
||||||
|
set +e
|
||||||
|
json=$(restic snapshots --host "${MOI}" --tag "${ETIQUETTE_AVANT_RASER}" --json 2>/dev/null)
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
if (( rc == 10 )); then return 0; fi
|
||||||
|
if (( rc != 0 )); then dire "depot illisible (restic rc=${rc}) : ${RESTIC_REPOSITORY}"; return 2; fi
|
||||||
|
printf '%s' "${json}" | python3 -c '
|
||||||
|
import json, sys
|
||||||
|
import datetime, re
|
||||||
|
def t(x):
|
||||||
|
s = re.sub(r"(\.\d{6})\d+", r"\1", x["time"])
|
||||||
|
return datetime.datetime.fromisoformat(re.sub(r"Z$", "+00:00", s)).timestamp()
|
||||||
|
s = sorted(json.load(sys.stdin) or [], key=t)
|
||||||
|
if s:
|
||||||
|
print(s[-1]["short_id"], s[-1]["time"][:19])
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Instantane a utiliser : impose, sinon le candidat. Vide = rien a restaurer.
|
||||||
|
INSTANTANE=""
|
||||||
|
resoudre_instantane() {
|
||||||
|
local c
|
||||||
|
c=$(candidat "${INSTANTANE}") || exit 2
|
||||||
|
INSTANTANE="${c%% *}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Le repertoire jetable ou l'instantane est extrait. Detruit a la sortie, quoi qu'il arrive.
|
||||||
|
TMP=""
|
||||||
|
trap 'if [[ -n "${TMP}" ]]; then rm -rf "${TMP}"; fi' EXIT
|
||||||
|
|
||||||
|
extraire() { # remplit TMP avec les chemins demandes de l'instantane
|
||||||
|
local inc=()
|
||||||
|
TMP=$(mktemp -d /var/tmp/setops-restauration.XXXXXX)
|
||||||
|
for c in "$@"; do inc+=(--include "${c}"); done
|
||||||
|
restic restore "${INSTANTANE}" --target "${TMP}" "${inc[@]}" >/dev/null \
|
||||||
|
|| mourir "restic restore ${INSTANTANE} a echoue"
|
||||||
|
}
|
||||||
|
|
||||||
|
MIS_DE_COTE=""
|
||||||
|
mettre_de_cote() { # $1 = chemin vivant ; MIS_DE_COTE = ou il est parti
|
||||||
|
MIS_DE_COTE="${MISE_DE_COTE}/$(date +%Y%m%d-%H%M%S)$1"
|
||||||
|
mkdir -p "$(dirname "${MIS_DE_COTE}")"
|
||||||
|
cp -a "$1" "${MIS_DE_COTE}"
|
||||||
|
dire "etat en place mis de cote : ${MIS_DE_COTE}"
|
||||||
|
}
|
||||||
|
|
||||||
|
vide() { # vrai si le chemin est absent, ou un repertoire sans contenu
|
||||||
|
[[ ! -e "$1" ]] || { [[ -d "$1" ]] && [[ -z "$(find "$1" -mindepth 1 -print -quit)" ]]; }
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_fichiers() {
|
||||||
|
local proprio="" remplacer=0 vers="" depuis="" chemins=() src dest n
|
||||||
|
while (( $# )); do
|
||||||
|
case "$1" in
|
||||||
|
--proprietaire) proprio="$2"; shift 2 ;;
|
||||||
|
--remplacer) remplacer=1; shift ;;
|
||||||
|
--vers) vers="$2"; shift 2 ;;
|
||||||
|
--depuis) depuis="$2"; shift 2 ;;
|
||||||
|
--instantane) INSTANTANE="$2"; shift 2 ;;
|
||||||
|
*) chemins+=("$1"); shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[[ -n "${chemins[*]:-}" ]] || mourir "fichiers : aucun chemin"
|
||||||
|
# LA COPIE A FROID (2026-10-07) : l'instantane porte la copie posee dans le repertoire de
|
||||||
|
# preparation, pas la base. `--depuis` dit ou la prendre ; le CHEMIN, ou la remettre.
|
||||||
|
# (Un second element, plutot qu'une longueur : la sequence accolade-diese ouvre un
|
||||||
|
# commentaire Jinja dans ce gabarit.)
|
||||||
|
if [[ -n "${depuis}" && -n "${chemins[1]:-}" ]]; then mourir "fichiers --depuis : un seul chemin"; fi
|
||||||
|
resoudre_instantane
|
||||||
|
if [[ -z "${INSTANTANE}" ]]; then echo "RIEN : aucune incarnation precedente."; return 0; fi
|
||||||
|
if [[ -n "${depuis}" ]]; then extraire "${depuis}"; else extraire "${chemins[@]}"; fi
|
||||||
|
for c in "${chemins[@]}"; do
|
||||||
|
src="${TMP}${depuis:-${c}}"
|
||||||
|
dest="${vers}${c}"
|
||||||
|
if [[ ! -e "${src}" ]]; then echo "ABSENT de ${INSTANTANE} : ${c}"; continue; fi
|
||||||
|
if ! vide "${dest}"; then
|
||||||
|
if [[ -z "${vers}" ]] && (( ! remplacer )); then
|
||||||
|
mourir "${dest} n'est pas vide : --remplacer pour l'ecraser (il sera mis de cote)"
|
||||||
|
fi
|
||||||
|
mettre_de_cote "${dest}"
|
||||||
|
fi
|
||||||
|
mkdir -p "$(dirname "${dest}")"
|
||||||
|
if [[ -d "${src}" ]]; then
|
||||||
|
mkdir -p "${dest}"
|
||||||
|
rsync -a --delete "${src}/" "${dest}/"
|
||||||
|
else
|
||||||
|
cp -a "${src}" "${dest}"
|
||||||
|
fi
|
||||||
|
if [[ -n "${proprio}" ]]; then chown -R "${proprio}" "${dest}"; fi
|
||||||
|
n=$(find "${dest}" -type f | wc -l)
|
||||||
|
echo "RESTAURE ${INSTANTANE} : ${c} -> ${dest} (${n} fichier(s))"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_annuaire() {
|
||||||
|
local essai=0 base="" ldif dir n
|
||||||
|
while (( $# )); do
|
||||||
|
case "$1" in
|
||||||
|
--essai) essai=1; shift ;;
|
||||||
|
--instantane) INSTANTANE="$2"; shift 2 ;;
|
||||||
|
*) base="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[[ -n "${base}" ]] || mourir "annuaire : BASE_DN requis"
|
||||||
|
resoudre_instantane
|
||||||
|
if [[ -z "${INSTANTANE}" ]]; then echo "RIEN : aucune incarnation precedente."; return 0; fi
|
||||||
|
extraire "$(dirname "${LDIF}")"
|
||||||
|
ldif="${TMP}${LDIF}"
|
||||||
|
[[ -s "${ldif}" ]] || mourir "${INSTANTANE} n'emporte pas d'annuaire (${LDIF})"
|
||||||
|
n=$(grep -c '^dn:' "${ldif}")
|
||||||
|
# A BLANC D'ABORD : un LDIF que le schema EN PLACE refuse (overlay pas encore charge,
|
||||||
|
# attribut inconnu) doit echouer ici, pas apres qu'on a deplace la base vivante.
|
||||||
|
slapadd -u -q -b "${base}" -l "${ldif}" || mourir "slapadd a blanc refuse ${INSTANTANE}"
|
||||||
|
if (( essai )); then echo "ESSAI OK : ${n} entree(s) rejouables depuis ${INSTANTANE}."; return 0; fi
|
||||||
|
dir=$(slapcat -n 0 -a "(olcSuffix=${base})" | awk '/^olcDbDirectory:/{print $2}')
|
||||||
|
[[ -d "${dir}" ]] || mourir "repertoire de la base introuvable pour ${base}"
|
||||||
|
systemctl stop slapd
|
||||||
|
mettre_de_cote "${dir}"
|
||||||
|
find "${dir}" -mindepth 1 -delete
|
||||||
|
if ! slapadd -q -b "${base}" -l "${ldif}"; then
|
||||||
|
dire "slapadd a echoue : remise de la base d'avant"
|
||||||
|
find "${dir}" -mindepth 1 -delete
|
||||||
|
cp -a "${MIS_DE_COTE}/." "${dir}/"
|
||||||
|
chown -R openldap:openldap "${dir}"
|
||||||
|
systemctl start slapd
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
chown -R openldap:openldap "${dir}"
|
||||||
|
systemctl start slapd
|
||||||
|
echo "RESTAURE ${INSTANTANE} : annuaire ${base}, ${n} entree(s)."
|
||||||
|
}
|
||||||
|
|
||||||
|
tables() { # nombre de tables hors schemas systeme ; -1 si la base n'existe pas
|
||||||
|
runuser -u postgres -- psql -tAX -d "$1" -c \
|
||||||
|
"select count(*) from information_schema.tables where table_schema not in ('pg_catalog','information_schema')" \
|
||||||
|
2>/dev/null || echo -1
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_base() {
|
||||||
|
local remplacer=0 vers="" nom="" cible dump section creation proprio n
|
||||||
|
while (( $# )); do
|
||||||
|
case "$1" in
|
||||||
|
--remplacer) remplacer=1; shift ;;
|
||||||
|
--vers) vers="$2"; shift 2 ;;
|
||||||
|
--instantane) INSTANTANE="$2"; shift 2 ;;
|
||||||
|
*) nom="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[[ -n "${nom}" ]] || mourir "base : NOM requis"
|
||||||
|
cible="${vers:-${nom}}"
|
||||||
|
resoudre_instantane
|
||||||
|
if [[ -z "${INSTANTANE}" ]]; then echo "RIEN : aucune incarnation precedente."; return 0; fi
|
||||||
|
extraire "$(dirname "${DUMP_PG}")"
|
||||||
|
dump="${TMP}${DUMP_PG}"
|
||||||
|
[[ -s "${dump}" ]] || mourir "${INSTANTANE} n'emporte pas de dump PostgreSQL"
|
||||||
|
creation=$(grep -m1 -E "^CREATE DATABASE ${nom} " "${dump}" || true)
|
||||||
|
if [[ -z "${creation}" ]]; then echo "ABSENTE de ${INSTANTANE} : base ${nom}"; return 0; fi
|
||||||
|
proprio=$(grep -m1 -oP "^ALTER DATABASE ${nom} OWNER TO \K[^;]+" "${dump}" || echo postgres)
|
||||||
|
# LE PIEGE DE pg_dumpall (runbook §5) : un `CREATE DATABASE <suivante>` et, avec
|
||||||
|
# `--clean`, un `DROP DATABASE postgres;` precedent le `\connect` suivant. On coupe sur
|
||||||
|
# les trois, puis on REFUSE toute section qui viserait encore une autre base.
|
||||||
|
section="${TMP}/section.sql"
|
||||||
|
awk -v db="${nom}" '$0 == "\\connect " db {f=1; next}
|
||||||
|
f && (/^\\connect / || /^CREATE DATABASE / || /^DROP DATABASE /) {exit} f' "${dump}" > "${section}"
|
||||||
|
if grep -qE '^(DROP|CREATE|ALTER) DATABASE|^\\connect' "${section}"; then
|
||||||
|
mourir "section ${nom} hors perimetre : refus"
|
||||||
|
fi
|
||||||
|
if [[ -n "${vers}" ]]; then sed -i -E "/ ON DATABASE ${nom} /d" "${section}"; fi
|
||||||
|
n=$(tables "${cible}")
|
||||||
|
if (( n > 0 )) && (( ! remplacer )); then
|
||||||
|
mourir "la base ${cible} contient ${n} table(s) : --remplacer pour l'ecraser (elle sera mise de cote)"
|
||||||
|
fi
|
||||||
|
if (( n >= 0 )); then
|
||||||
|
mkdir -p "${MISE_DE_COTE}"
|
||||||
|
runuser -u postgres -- pg_dump -Fc "${cible}" > "${MISE_DE_COTE}/$(date +%Y%m%d-%H%M%S)-${cible}.dump"
|
||||||
|
runuser -u postgres -- dropdb --force "${cible}"
|
||||||
|
fi
|
||||||
|
runuser -u postgres -- psql -X -q -v ON_ERROR_STOP=1 -d postgres \
|
||||||
|
-c "${creation/CREATE DATABASE ${nom} /CREATE DATABASE ${cible} }" \
|
||||||
|
-c "ALTER DATABASE ${cible} OWNER TO ${proprio};"
|
||||||
|
# UNE SEULE TRANSACTION : une erreur au milieu laisse une base VIDE, que la prochaine
|
||||||
|
# passe retentera — pas une base a moitie chargee qu'on prendrait pour la bonne.
|
||||||
|
# Par l'ENTREE STANDARD, pas `-f` : psql tourne en `postgres`, qui ne peut pas lire le
|
||||||
|
# repertoire jetable de root (0700). Trouve en repetition sur data-sql-01, 2026-09-30.
|
||||||
|
runuser -u postgres -- psql -X -q --single-transaction -v ON_ERROR_STOP=1 \
|
||||||
|
-d "${cible}" < "${section}" >/dev/null
|
||||||
|
echo "RESTAURE ${INSTANTANE} : base ${nom} -> ${cible}, $(tables "${cible}") table(s)."
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_acter() {
|
||||||
|
local jeu="${1:?jeu}" etat="${2:?etat}" id="${3:-}"
|
||||||
|
install -d -m 0700 "${MARQUES}"
|
||||||
|
printf 'etat=%s\ninstantane=%s\ndate=%s\n' "${etat}" "${id}" "$(date -Is)" > "${MARQUES}/${jeu}"
|
||||||
|
echo "ACTE ${jeu} : ${etat}${id:+ (${id})}"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_choisir() {
|
||||||
|
[[ "${1:-}" == "--instantane" ]] && INSTANTANE="${2:-}"
|
||||||
|
local c
|
||||||
|
c=$(candidat "${INSTANTANE}") || exit 2
|
||||||
|
python3 -c 'import json,sys; print(json.dumps({"instantane": sys.argv[1], "heure": sys.argv[2], "naissance": int(sys.argv[3])}))' \
|
||||||
|
"${c%% *}" "$( [[ -n "${c}" ]] && printf '%s' "${c#* }" )" "$(naissance)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# LE CANDIDAT EST CALCULE MAINTENANT (2026-10-07). Il dit ce qu'une restauration lancee a
|
||||||
|
# cette minute choisirait, pas ce qui a ete restaure : au bilan de M4, il nommait
|
||||||
|
# l'instantane de la veille, et l'on a cru un moment que la journee etait perdue. Ce qui a
|
||||||
|
# ete restaure est dans le marqueur de chaque jeu ; on dit desormais s'il est encore au depot.
|
||||||
|
cmd_etat() {
|
||||||
|
local c a ids id present
|
||||||
|
echo "hote : ${MOI}"
|
||||||
|
echo "naissance : $(date -d "@$(naissance)" -Is)"
|
||||||
|
c=$(candidat "") || exit 2
|
||||||
|
# (Pas d'apostrophe dans `${c:-...}` : entre guillemets doubles, bash l'y lit comme un
|
||||||
|
# guillemet ouvrant, et tout le script devient illisible.)
|
||||||
|
if [[ -z "${c}" ]]; then c="aucun (premiere vie, ou rien avant la naissance)"; fi
|
||||||
|
echo "candidat : ${c} (ce que choisirait une restauration lancee maintenant)"
|
||||||
|
a=$(avant_raser) || exit 2
|
||||||
|
echo "avant-raser: ${a:-aucun}"
|
||||||
|
ids=$(restic snapshots --host "${MOI}" --json 2>/dev/null \
|
||||||
|
| python3 -c 'import json, sys; print(" ".join(x["short_id"] for x in json.load(sys.stdin) or []))' \
|
||||||
|
2>/dev/null || true)
|
||||||
|
for j in "${JEUX[@]}"; do
|
||||||
|
if [[ -f "${MARQUES}/${j}" ]]; then
|
||||||
|
id=$(sed -n 's/^instantane=//p' "${MARQUES}/${j}")
|
||||||
|
present=""
|
||||||
|
if [[ -n "${id}" ]]; then
|
||||||
|
if [[ " ${ids} " == *" ${id} "* ]]; then present="(au depot)"; else present="(RETIRE du depot)"; fi
|
||||||
|
fi
|
||||||
|
echo "jeu ${j} : $(tr '\n' ' ' < "${MARQUES}/${j}")${present}"
|
||||||
|
else
|
||||||
|
echo "jeu ${j} : EN ATTENTE (aucun marqueur)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# LES TEMOINS (2026-10-07). L'etat vivant est-il celui d'avant le rasage ? On extrait
|
||||||
|
# l'instantane d'avant (etiquete, sinon le candidat ou celui qu'on impose), on lit
|
||||||
|
# l'annuaire vivant, et `setops-temoins` compare. Rien n'est ecrit hors du repertoire jetable.
|
||||||
|
cmd_temoins() {
|
||||||
|
local source="avant-raser" c heure base chemins=() args=()
|
||||||
|
while (( $# )); do
|
||||||
|
case "$1" in
|
||||||
|
--candidat) source="candidat"; shift ;;
|
||||||
|
--instantane) INSTANTANE="$2"; source="impose"; shift 2 ;;
|
||||||
|
*) mourir "temoins : option inconnue : $1" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if [[ -z "${JEUX[*]:-}" ]]; then echo "SANS OBJET : ${MOI} ne detient aucun etat."; exit 4; fi
|
||||||
|
case "${source}" in
|
||||||
|
avant-raser) c=$(avant_raser) || exit 2 ;;
|
||||||
|
candidat) c=$(candidat "") || exit 2 ;;
|
||||||
|
impose) c=$(candidat "${INSTANTANE}") || exit 2 ;;
|
||||||
|
esac
|
||||||
|
if [[ -z "${c}" ]]; then echo "SANS OBJET : aucun instantane ${source} pour ${MOI}."; exit 4; fi
|
||||||
|
INSTANTANE="${c%% *}"
|
||||||
|
heure="${c#* }"
|
||||||
|
for j in "${JEUX[@]}"; do
|
||||||
|
IFS=: read -r -a morceaux <<< "${CHEMINS_JEU[${j}]}"
|
||||||
|
chemins+=("${morceaux[@]}")
|
||||||
|
args+=(--jeu "${j}=${CHEMINS_JEU[${j}]}")
|
||||||
|
if [[ -n "${VIVANT_JEU[${j}]:-}" ]]; then args+=(--vivant "${j}=${VIVANT_JEU[${j}]}"); fi
|
||||||
|
done
|
||||||
|
extraire "${chemins[@]}"
|
||||||
|
if [[ -s "${TMP}${LDIF}" ]]; then
|
||||||
|
base=$(sed -n 's/^dn: //p' "${TMP}${LDIF}" | head -n 1)
|
||||||
|
slapcat -b "${base}" > "${TMP}/vivant-annuaire.ldif" || mourir "slapcat ${base} a echoue"
|
||||||
|
args+=(--ldif "${LDIF}" --ldif-vivant "${TMP}/vivant-annuaire.ldif")
|
||||||
|
fi
|
||||||
|
set +e
|
||||||
|
/usr/local/sbin/setops-temoins --avant "${TMP}" --instantane "${INSTANTANE}" \
|
||||||
|
--heure "${heure}" --hote "${MOI}" --dump "${DUMP_PG}" "${args[@]}"
|
||||||
|
local rc=$?
|
||||||
|
set -e
|
||||||
|
exit "${rc}"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_garde() {
|
||||||
|
local attente=() c
|
||||||
|
for j in "${JEUX[@]}"; do
|
||||||
|
[[ -f "${MARQUES}/${j}" ]] || attente+=("${j}")
|
||||||
|
done
|
||||||
|
if [[ -z "${attente[*]:-}" ]]; then return 0; fi
|
||||||
|
c=$(candidat "") || exit 2
|
||||||
|
if [[ -z "${c}" ]]; then
|
||||||
|
for j in "${attente[@]}"; do cmd_acter "${j}" neuf >/dev/null; done
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
dire "REFUS : l'etat d'une incarnation precedente (${c}) attend dans le depot, non remis"
|
||||||
|
dire "pour : ${attente[*]}. Deposer maintenant pousserait l'instantane d'avant hors de"
|
||||||
|
dire "la retention. Le remettre (redeployer le role proprietaire), ou l'ecarter :"
|
||||||
|
dire " make restauration-renoncer HOTE=${MOI} JEU=<jeu> CONFIRMER=true"
|
||||||
|
exit 3
|
||||||
|
}
|
||||||
|
|
||||||
|
sous="${1:-etat}"; shift || true
|
||||||
|
case "${sous}" in
|
||||||
|
etat) cmd_etat ;;
|
||||||
|
choisir) cmd_choisir "$@" ;;
|
||||||
|
fichiers) cmd_fichiers "$@" ;;
|
||||||
|
annuaire) cmd_annuaire "$@" ;;
|
||||||
|
base) cmd_base "$@" ;;
|
||||||
|
acter) cmd_acter "$@" ;;
|
||||||
|
garde) cmd_garde ;;
|
||||||
|
temoins) cmd_temoins "$@" ;;
|
||||||
|
*) mourir "sous-commande inconnue : ${sous}" ;;
|
||||||
|
esac
|
||||||
|
|
@ -4,6 +4,31 @@
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
export RESTIC_REPOSITORY="{{ client_backup_repo }}"
|
export RESTIC_REPOSITORY="{{ client_backup_repo }}"
|
||||||
export RESTIC_PASSWORD_FILE="/etc/setops/restic.pass"
|
export RESTIC_PASSWORD_FILE="/etc/setops/restic.pass"
|
||||||
|
# UN VERROU N'EST PAS UNE PANNE (2026-10-04). La sauvegarde, la verification du depot, le
|
||||||
|
# controle de restauration et `setops-restaurer` partagent ce depot ; leurs minuteurs peuvent
|
||||||
|
# partir ensemble (`Persistent=true` rattrape au demarrage tout ce qui a ete manque). Sans
|
||||||
|
# attente, restic refuse aussitot un depot verrouille, et une sonde concluait « aucun
|
||||||
|
# instantane » ou « depot corrompu ». On attend le verrou au lieu de conclure.
|
||||||
|
restic() { command restic --retry-lock {{ client_backup_attente_verrou }} "$@"; }
|
||||||
|
|
||||||
|
# L'INSTANTANE D'AVANT RASAGE (2026-10-07). `--avant-raser` (unite
|
||||||
|
# `setops-sauvegarde-avant-raser`, lancee par `make sauvegarder-maintenant`) etiquette le
|
||||||
|
# depot « {{ client_backup_etiquette_avant_raser }} », et la retention garde cette etiquette.
|
||||||
|
# Sans elle, le premier depot de la machine reconstruite, le meme jour, chassait l'instantane
|
||||||
|
# d'avant par `--keep-daily` : apres la reconstruction de Technolibre (M4), il ne restait
|
||||||
|
# rien a quoi comparer l'etat remis. Un seul porte l'etiquette, le dernier : il est garde
|
||||||
|
# jusqu'a la reconstruction suivante, qui la lui retire.
|
||||||
|
AVANT_RASER=0
|
||||||
|
if [[ "${1:-}" == "--avant-raser" ]]; then AVANT_RASER=1; fi
|
||||||
|
ETIQUETTES=(--tag setops)
|
||||||
|
if (( AVANT_RASER )); then ETIQUETTES+=(--tag "{{ client_backup_etiquette_avant_raser }}"); fi
|
||||||
|
|
||||||
|
# 0. UN ETAT D'AVANT ATTEND-IL ? (2026-09-30)
|
||||||
|
# Sur une machine reconstruite, tant que l'etat de l'incarnation precedente n'a ete ni
|
||||||
|
# remis ni ecarte, deposer serait NUISIBLE : `restic forget --keep-daily` garde un
|
||||||
|
# instantane par jour, et celui de l'etat neuf chasserait celui d'avant, s'ils tombent le
|
||||||
|
# meme jour. On refuse donc, bruyamment (code 3). Voir `setops-restaurer garde`.
|
||||||
|
/usr/local/sbin/setops-restaurer garde
|
||||||
|
|
||||||
# 1. Dumps applicatifs (pg_dump, slapcat, forgejo dump...) vers le staging.
|
# 1. Dumps applicatifs (pg_dump, slapcat, forgejo dump...) vers le staging.
|
||||||
{% for job in client_backup_jobs %}
|
{% for job in client_backup_jobs %}
|
||||||
|
|
@ -47,7 +72,24 @@ if (( N_PRESENTS == 0 )); then
|
||||||
echo "aucun chemin declare n'existe encore sur cet hote — rien a sauvegarder." >&2
|
echo "aucun chemin declare n'existe encore sur cet hote — rien a sauvegarder." >&2
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
restic backup --host "{{ inventory_hostname }}" --tag setops "${PRESENTS[@]}"
|
restic backup --host "{{ inventory_hostname }}" "${ETIQUETTES[@]}" "${PRESENTS[@]}"
|
||||||
|
|
||||||
|
# 3 bis. L'etiquette passe au dernier depot d'avant rasage : les precedents rentrent dans
|
||||||
|
# la retention ordinaire. APRES le depot, jamais avant : s'il echoue, l'ancien reste garde.
|
||||||
|
if (( AVANT_RASER )); then
|
||||||
|
ANCIENS=$(restic snapshots --host "{{ inventory_hostname }}" --tag "{{ client_backup_etiquette_avant_raser }}" --json \
|
||||||
|
| python3 -c 'import json, sys
|
||||||
|
import datetime, re
|
||||||
|
def t(x):
|
||||||
|
s = re.sub(r"(\.\d{6})\d+", r"\1", x["time"])
|
||||||
|
return datetime.datetime.fromisoformat(re.sub(r"Z$", "+00:00", s)).timestamp()
|
||||||
|
s = sorted(json.load(sys.stdin) or [], key=t)
|
||||||
|
print(" ".join(x["id"] for x in s[:-1]))')
|
||||||
|
if [[ -n "${ANCIENS}" ]]; then
|
||||||
|
read -r -a ANCIENS_IDS <<< "${ANCIENS}"
|
||||||
|
restic tag --remove "{{ client_backup_etiquette_avant_raser }}" "${ANCIENS_IDS[@]}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# 4. Rétention.
|
# 4. Rétention.
|
||||||
restic forget {{ client_backup_retention }} --prune
|
restic forget {{ client_backup_retention }} --keep-tag "{{ client_backup_etiquette_avant_raser }}" --prune
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
# Géré par Set-OPS (rôle client_backup). Ne pas éditer à la main.
|
||||||
|
# Le depot d'avant rasage : etiquete, garde jusqu'a la reconstruction suivante.
|
||||||
|
# Lance par `make sauvegarder-maintenant` (playbooks/backup/restauration.yml), jamais par un minuteur.
|
||||||
|
[Unit]
|
||||||
|
Description=Sauvegarde Set-OPS d'avant rasage (restic hors-nœud, etiquetee)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/sbin/setops-sauvegarder.sh --avant-raser
|
||||||
|
Nice=10
|
||||||
|
IOSchedulingClass=idle
|
||||||
|
|
@ -14,6 +14,12 @@
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
export RESTIC_PASSWORD_FILE="/etc/setops/restic.pass"
|
export RESTIC_PASSWORD_FILE="/etc/setops/restic.pass"
|
||||||
|
# UN VERROU N'EST PAS UNE PANNE (2026-10-04). La sauvegarde, la verification du depot, le
|
||||||
|
# controle de restauration et `setops-restaurer` partagent ce depot ; leurs minuteurs peuvent
|
||||||
|
# partir ensemble (`Persistent=true` rattrape au demarrage tout ce qui a ete manque). Sans
|
||||||
|
# attente, restic refuse aussitot un depot verrouille, et une sonde concluait « aucun
|
||||||
|
# instantane » ou « depot corrompu ». On attend le verrou au lieu de conclure.
|
||||||
|
restic() { command restic --retry-lock {{ client_backup_attente_verrou }} "$@"; }
|
||||||
export RESTIC_REPOSITORY="{{ client_backup_repo }}"
|
export RESTIC_REPOSITORY="{{ client_backup_repo }}"
|
||||||
API="https://{{ client_backup_icinga_hote }}.{{ domaine_interne }}:5665"
|
API="https://{{ client_backup_icinga_hote }}.{{ domaine_interne }}:5665"
|
||||||
TTL={{ client_backup_ttl_icinga }}
|
TTL={{ client_backup_ttl_icinga }}
|
||||||
|
|
@ -22,6 +28,16 @@ AGE_CRIT={{ client_backup_age_crit_h }}
|
||||||
MOI="{{ inventory_hostname }}"
|
MOI="{{ inventory_hostname }}"
|
||||||
MOTDEPASSE="$(cat /etc/setops/icinga-api.pass)"
|
MOTDEPASSE="$(cat /etc/setops/icinga-api.pass)"
|
||||||
|
|
||||||
|
# Les chemins que `setops-sauvegarde` emporte : de quoi dire, devant un instantane vide,
|
||||||
|
# s'il n'y avait RIEN a emporter ou si la sauvegarde a manque des donnees bien presentes.
|
||||||
|
CHEMINS=(
|
||||||
|
{% for job in client_backup_jobs %}
|
||||||
|
{% for c in job.chemins %}
|
||||||
|
"{{ c }}"
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
)
|
||||||
|
|
||||||
rapporter() { # $1=code $2=texte
|
rapporter() { # $1=code $2=texte
|
||||||
local charge reponse
|
local charge reponse
|
||||||
charge=$(python3 -c 'import json,sys; print(json.dumps({
|
charge=$(python3 -c 'import json,sys; print(json.dumps({
|
||||||
|
|
@ -40,9 +56,17 @@ rapporter() { # $1=code $2=texte
|
||||||
|
|
||||||
maintenant=$(date +%s)
|
maintenant=$(date +%s)
|
||||||
|
|
||||||
|
# LE PLUS RECENT DE TOUS LES GROUPES, PAS LE PREMIER (2026-10-08). `--latest 1` rend le
|
||||||
|
# dernier instantane de CHAQUE groupe — machine ET liste de chemins. Ajouter un jeu change la
|
||||||
|
# liste : deux groupes, et `d[0]` etait l'ancien, fige. Sur site-mon-01, des le lendemain de
|
||||||
|
# l'arrivee de prometheus, loki et icinga : « EN RETARD : 26 h » sur une sauvegarde de 2 h 30,
|
||||||
|
# et « PERIME » promis a 50 h. On prend donc le plus recent de tous.
|
||||||
json=$(restic snapshots --latest 1 --json 2>/dev/null)
|
json=$(restic snapshots --latest 1 --json 2>/dev/null)
|
||||||
horodatage=$(printf '%s' "${json}" | python3 -c \
|
horodatage=$(printf '%s' "${json}" | python3 -c \
|
||||||
'import sys,json;d=json.load(sys.stdin);print(d[0]["time"] if d else "")' 2>/dev/null)
|
'import sys,json,re,datetime
|
||||||
|
d=json.load(sys.stdin) or []
|
||||||
|
t=lambda x: datetime.datetime.fromisoformat(re.sub(r"Z$","+00:00",re.sub(r"(\.\d{6})\d+",r"\1",x["time"]))).timestamp()
|
||||||
|
print(max(d,key=t)["time"] if d else "")' 2>/dev/null)
|
||||||
if [[ -z "${horodatage}" ]]; then
|
if [[ -z "${horodatage}" ]]; then
|
||||||
# ON NE DISTINGUE PAS ICI « depot vide » de « depot injoignable », et c'est voulu :
|
# ON NE DISTINGUE PAS ICI « depot vide » de « depot injoignable », et c'est voulu :
|
||||||
# dans les deux cas il n'y a PAS de sauvegarde exploitable, et c'est ce qui compte.
|
# dans les deux cas il n'y a PAS de sauvegarde exploitable, et c'est ce qui compte.
|
||||||
|
|
@ -68,10 +92,27 @@ for l in sys.stdin:
|
||||||
print(n)' 2>/dev/null || echo 0)
|
print(n)' 2>/dev/null || echo 0)
|
||||||
|
|
||||||
if (( fichiers == 0 )); then
|
if (( fichiers == 0 )); then
|
||||||
# AVERTISSEMENT et non CRITIQUE : la machine ne peut pas distinguer « les donnees ont
|
# INSTANTANE VIDE : on regarde ce que les chemins sauvegardes contiennent ICI, sur le
|
||||||
# disparu » de « il n'y en a pas encore » (un /srv/web sans webapp). C'est a un humain
|
# noeud. Trois cas, trois messages (2026-10-04) :
|
||||||
# de trancher — mais il doit le VOIR.
|
# - rien sur le noeud : rien a sauvegarder, l'instantane vide est juste. Avertissement
|
||||||
rapporter 1 "N'EMPORTE RIEN : instantane sans aucun fichier (${taille} octets). Legitime si ce noeud n'a pas encore de donnees — a confirmer."
|
# quand meme : un humain doit savoir que ce noeud n'est protege de rien ;
|
||||||
|
# - des fichiers posterieurs a l'instantane : ils sont arrives depuis, la prochaine
|
||||||
|
# sauvegarde les emportera. Avertissement ;
|
||||||
|
# - des fichiers deja la AU MOMENT de l'instantane : la sauvegarde les a manques.
|
||||||
|
# CRITIQUE — c'est une vraie panne.
|
||||||
|
sur_place=0; anciens=0
|
||||||
|
for c in "${CHEMINS[@]}"; do
|
||||||
|
[[ -e "$c" ]] || continue
|
||||||
|
sur_place=$(( sur_place + $(find "$c" -type f 2>/dev/null | wc -l) ))
|
||||||
|
anciens=$(( anciens + $(find "$c" -type f ! -newermt "@${secondes}" 2>/dev/null | wc -l) ))
|
||||||
|
done
|
||||||
|
if (( sur_place == 0 )); then
|
||||||
|
rapporter 1 "RIEN A SAUVEGARDER : ${CHEMINS[*]} ne contient aucun fichier sur ce noeud ; instantane vide, comme attendu."
|
||||||
|
elif (( anciens == 0 )); then
|
||||||
|
rapporter 1 "DONNEES ARRIVEES DEPUIS : ${sur_place} fichier(s) dans ${CHEMINS[*]}, tous posterieurs au dernier instantane (il y a ${age_h} h, vide). La prochaine sauvegarde les emportera."
|
||||||
|
else
|
||||||
|
rapporter 2 "N'EMPORTE RIEN : instantane vide alors que ${CHEMINS[*]} contenait deja ${anciens} fichier(s) a sa prise. La sauvegarde manque ses donnees."
|
||||||
|
fi
|
||||||
elif (( age_h >= AGE_CRIT )); then
|
elif (( age_h >= AGE_CRIT )); then
|
||||||
rapporter 2 "PERIME : dernier instantane il y a ${age_h} h (seuil ${AGE_CRIT} h), ${fichiers} fichier(s)."
|
rapporter 2 "PERIME : dernier instantane il y a ${age_h} h (seuil ${AGE_CRIT} h), ${fichiers} fichier(s)."
|
||||||
elif (( age_h >= AGE_WARN )); then
|
elif (( age_h >= AGE_WARN )); then
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,12 @@
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
export RESTIC_PASSWORD_FILE="/etc/setops/restic.pass"
|
export RESTIC_PASSWORD_FILE="/etc/setops/restic.pass"
|
||||||
|
# UN VERROU N'EST PAS UNE PANNE (2026-10-04). La sauvegarde, la verification du depot, le
|
||||||
|
# controle de restauration et `setops-restaurer` partagent ce depot ; leurs minuteurs peuvent
|
||||||
|
# partir ensemble (`Persistent=true` rattrape au demarrage tout ce qui a ete manque). Sans
|
||||||
|
# attente, restic refuse aussitot un depot verrouille, et une sonde concluait « aucun
|
||||||
|
# instantane » ou « depot corrompu ». On attend le verrou au lieu de conclure.
|
||||||
|
restic() { command restic --retry-lock {{ client_backup_attente_verrou }} "$@"; }
|
||||||
export RESTIC_REPOSITORY="{{ client_backup_repo }}"
|
export RESTIC_REPOSITORY="{{ client_backup_repo }}"
|
||||||
API="https://{{ client_backup_icinga_hote }}.{{ domaine_interne }}:5665"
|
API="https://{{ client_backup_icinga_hote }}.{{ domaine_interne }}:5665"
|
||||||
TTL={{ client_backup_ttl_restauration }}
|
TTL={{ client_backup_ttl_restauration }}
|
||||||
|
|
@ -26,6 +32,15 @@ PART="{{ client_backup_restauration_part }}"
|
||||||
MOI="{{ inventory_hostname }}"
|
MOI="{{ inventory_hostname }}"
|
||||||
MOTDEPASSE="$(cat /etc/setops/icinga-api.pass)"
|
MOTDEPASSE="$(cat /etc/setops/icinga-api.pass)"
|
||||||
|
|
||||||
|
# Les chemins que `setops-sauvegarde` emporte (voir `setops-verifier-mon-depot.sh`).
|
||||||
|
CHEMINS=(
|
||||||
|
{% for job in client_backup_jobs %}
|
||||||
|
{% for c in job.chemins %}
|
||||||
|
"{{ c }}"
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
)
|
||||||
|
|
||||||
rapporter() { # $1=code $2=texte
|
rapporter() { # $1=code $2=texte
|
||||||
local charge reponse
|
local charge reponse
|
||||||
charge=$(python3 -c 'import json,sys; print(json.dumps({
|
charge=$(python3 -c 'import json,sys; print(json.dumps({
|
||||||
|
|
@ -76,7 +91,17 @@ octets=$(du -sb "${CIBLE}" | cut -f1)
|
||||||
if (( obtenus != attendus )); then
|
if (( obtenus != attendus )); then
|
||||||
rapporter 2 "RESTAURATION INCOMPLETE : ${obtenus} fichier(s) restaure(s) sur ${attendus} dans l'instantane."
|
rapporter 2 "RESTAURATION INCOMPLETE : ${obtenus} fichier(s) restaure(s) sur ${attendus} dans l'instantane."
|
||||||
elif (( attendus == 0 )); then
|
elif (( attendus == 0 )); then
|
||||||
rapporter 1 "Instantane restaurable mais VIDE : rien a restaurer. Legitime si ce noeud n'a pas encore de donnees."
|
# Avertissement dans les deux cas ; le message dit lequel. Le jugement d'une sauvegarde
|
||||||
|
# qui manque ses donnees revient a la sonde `sauvegarde`, qui sait dater l'instantane.
|
||||||
|
sur_place=0
|
||||||
|
for c in "${CHEMINS[@]}"; do
|
||||||
|
[[ -e "$c" ]] && sur_place=$(( sur_place + $(find "$c" -type f 2>/dev/null | wc -l) ))
|
||||||
|
done
|
||||||
|
if (( sur_place == 0 )); then
|
||||||
|
rapporter 1 "RIEN A RESTAURER : instantane vide, et ${CHEMINS[*]} ne contient aucun fichier sur ce noeud — comme attendu."
|
||||||
|
else
|
||||||
|
rapporter 1 "Instantane restaurable mais VIDE, alors que ${CHEMINS[*]} contient ${sur_place} fichier(s) sur ce noeud : voir la sonde sauvegarde."
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
rapporter 0 "Restauration verifiee : ${obtenus} fichier(s), ${octets} octets, contenu recompare ; integrite du depot relue sur ${PART}."
|
rapporter 0 "Restauration verifiee : ${obtenus} fichier(s), ${octets} octets, contenu recompare ; integrite du depot relue sur ${PART}."
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -19,5 +19,7 @@ client_backup_groupes_etat:
|
||||||
- serveur_forgejo
|
- serveur_forgejo
|
||||||
- serveur_nextcloud
|
- serveur_nextcloud
|
||||||
- serveur_rspamd
|
- serveur_rspamd
|
||||||
- serveur_web_frontal
|
|
||||||
- serveur_web_dorsal
|
- serveur_web_dorsal
|
||||||
|
- serveur_icinga
|
||||||
|
- serveur_prometheus
|
||||||
|
- serveur_loki
|
||||||
|
|
|
||||||
|
|
@ -88,3 +88,11 @@ client_journal_syslog_emetteur: "frontiere"
|
||||||
# une liste de plus qui suit une autre — et qui prendrait du retard.
|
# une liste de plus qui suit une autre — et qui prendrait du retard.
|
||||||
client_journal_audit_actif: "{{ 'serveur_durci' in group_names }}"
|
client_journal_audit_actif: "{{ 'serveur_durci' in group_names }}"
|
||||||
client_journal_audit_chemin: "/var/log/audit/audit.log"
|
client_journal_audit_chemin: "/var/log/audit/audit.log"
|
||||||
|
|
||||||
|
# LES MACHINES QUI SONDENT — celles qui portent `client_sante`, donc qui jouent la sonde
|
||||||
|
# « connectivite » chaque minute. Leurs connexions de sonde ne sont pas expediees (voir le
|
||||||
|
# gabarit). Derive de l'inventaire : une machine ajoutee y entre sans qu'on l'ecrive.
|
||||||
|
# Vide = aucun filtre.
|
||||||
|
client_journal_sondeurs: >-
|
||||||
|
{{ groups.get('client_sante', []) | map('extract', hostvars)
|
||||||
|
| map(attribute='ansible_host', default='') | select('match', '^[0-9.]+$') | list }}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
# precedent : ce qui AUGMENTE est une perte en cours, ce qui stagne est une cicatrice.
|
# precedent : ce qui AUGMENTE est une perte en cours, ce qui stagne est une cicatrice.
|
||||||
sondes:
|
sondes:
|
||||||
- nom: journaux
|
- nom: journaux
|
||||||
|
metier: socle
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
raison: >-
|
raison: >-
|
||||||
Les journaux de cette machine partent-ils vraiment vers Loki ? Alloy peut tourner,
|
Les journaux de cette machine partent-ils vraiment vers Loki ? Alloy peut tourner,
|
||||||
|
|
|
||||||
|
|
@ -194,12 +194,17 @@
|
||||||
notify: Redemarrer alloy
|
notify: Redemarrer alloy
|
||||||
|
|
||||||
# Le port d'ecoute est IMPOSE ici plutot que subi : voir defaults/main.yml.
|
# Le port d'ecoute est IMPOSE ici plutot que subi : voir defaults/main.yml.
|
||||||
- name: Imposer le port de l interface locale d Alloy
|
#
|
||||||
|
# AUCUN RAPPORT D'USAGE (mesure du 2026-10-10). Par defaut, Alloy envoie a
|
||||||
|
# stats.grafana.org la liste de ses composants actifs : relevé a la frontiere depuis
|
||||||
|
# CHAQUE machine des trois ecosystemes, ~70 connexions par jour et par locataire. Il
|
||||||
|
# passait par la sortie 443 generale du socle, qui n'existe plus.
|
||||||
|
- name: Imposer le port de l interface locale d Alloy, sans rapport d usage
|
||||||
ansible.builtin.lineinfile:
|
ansible.builtin.lineinfile:
|
||||||
path: /etc/default/alloy
|
path: /etc/default/alloy
|
||||||
regexp: "^CUSTOM_ARGS="
|
regexp: "^CUSTOM_ARGS="
|
||||||
line: >-
|
line: >-
|
||||||
CUSTOM_ARGS="--server.http.listen-addr={{ client_journal_http_adresse }}:{{ client_journal_http_port }}"
|
CUSTOM_ARGS="--server.http.listen-addr={{ client_journal_http_adresse }}:{{ client_journal_http_port }} --disable-reporting"
|
||||||
create: false
|
create: false
|
||||||
notify: Redemarrer alloy
|
notify: Redemarrer alloy
|
||||||
|
|
||||||
|
|
@ -225,11 +230,16 @@
|
||||||
# perdre ferait passer une cicatrice ancienne pour une perte en cours.
|
# perdre ferait passer une cicatrice ancienne pour une perte en cours.
|
||||||
- name: Assurer le repertoire d etat des sondes
|
- name: Assurer le repertoire d etat des sondes
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
|
# 0755, COMME PARTOUT AILLEURS (2026-10-01) : quatre roles tenaient ce repertoire, trois
|
||||||
|
# en 0750 et `serveur_artefacts` en 0755 — apt-cacher-ng doit le traverser pour servir
|
||||||
|
# le depot de binaires du site. Le dernier deploye gagnait : au site, le 2026-10-01, il
|
||||||
|
# rendait 403, et chaque runner allait chercher 500 Mo sur Internet. Le repertoire ne
|
||||||
|
# tient que des etats de sondes, deja lisibles. Garde : test_repertoires_partages.py.
|
||||||
path: /var/lib/setops
|
path: /var/lib/setops
|
||||||
state: directory
|
state: directory
|
||||||
owner: root
|
owner: root
|
||||||
group: root
|
group: root
|
||||||
mode: "0750"
|
mode: "0755"
|
||||||
|
|
||||||
- name: Deposer la sonde d expedition des journaux
|
- name: Deposer la sonde d expedition des journaux
|
||||||
ansible.builtin.template:
|
ansible.builtin.template:
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,85 @@ loki.write "loki" {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loki.source.journal "journal" {
|
{% 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]
|
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.process.sondes.receiver' if sondeurs else 'loki.write.loki.receiver' }}]
|
||||||
max_age = "12h"
|
max_age = "12h"
|
||||||
labels = {
|
labels = {
|
||||||
job = "systemd-journal",
|
job = "systemd-journal",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
# donc DECLARES sans objet, dans une liste qu'une machine de metal peut vider.
|
# donc DECLARES sans objet, dans une liste qu'une machine de metal peut vider.
|
||||||
sondes:
|
sondes:
|
||||||
- nom: metriques
|
- nom: metriques
|
||||||
|
metier: socle
|
||||||
# Six fois la periode du porteur (15 min) : cinq passages peuvent manquer avant
|
# Six fois la periode du porteur (15 min) : cinq passages peuvent manquer avant
|
||||||
# qu'Icinga ne perime. On ne crie pas pour un hoquet, on crie pour un silence.
|
# qu'Icinga ne perime. On ne crie pas pour un hoquet, on crie pour un silence.
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,16 @@ client_pki_cle: "{{ client_pki_steppath }}/certs/{{ client_pki_nom_cert }}.key"
|
||||||
# ON N'AJOUTE QUE CE QUE CET HOTE SERT REELLEMENT : `edge` nomme le groupe qui rend le
|
# ON N'AJOUTE QUE CE QUE CET HOTE SERT REELLEMENT : `edge` nomme le groupe qui rend le
|
||||||
# service, et l'hote doit en faire partie. Un certificat qui revendiquerait le nom d'un
|
# service, et l'hote doit en faire partie. Un certificat qui revendiquerait le nom d'un
|
||||||
# service rendu ailleurs serait une usurpation, pas une commodite.
|
# service rendu ailleurs serait une usurpation, pas une commodite.
|
||||||
|
#
|
||||||
|
# L'AC INTERNE NE SIGNE QUE LE DOMAINE INTERNE (2026-09-29). Le web frontal sert les noms
|
||||||
|
# PUBLICS du locataire : ils relevent de Let's Encrypt. step-ca n'a aucune politique de noms
|
||||||
|
# et les aurait signes — sans danger (personne dehors ne lui fait confiance), mais sans objet.
|
||||||
|
#
|
||||||
|
# ET LE SERVICE LUI-MEME, QUAND LES MACHINES Y VONT DIRECTEMENT (2026-09-29). Avec
|
||||||
|
# `resolution_interne: service` (le site), le plancher et la zone menent le nom au service,
|
||||||
|
# pas a l'edge : c'est lui qui doit le porter. Sans cela, une reconstruction de
|
||||||
|
# `site-forge-01` aurait rendu un certificat sans `forge.genese.internal`, et le runner du
|
||||||
|
# site — qui y va directement — aurait bute sur le nom.
|
||||||
client_pki_expositions: "{{ hosts_statiques_expositions | default([]) }}"
|
client_pki_expositions: "{{ hosts_statiques_expositions | default([]) }}"
|
||||||
client_pki_sans: >-
|
client_pki_sans: >-
|
||||||
{{ ([client_pki_nom_cert,
|
{{ ([client_pki_nom_cert,
|
||||||
|
|
@ -52,6 +62,14 @@ client_pki_sans: >-
|
||||||
| selectattr('edge', 'defined')
|
| selectattr('edge', 'defined')
|
||||||
| selectattr('fqdn', 'defined')
|
| selectattr('fqdn', 'defined')
|
||||||
| selectattr('edge', 'in', group_names)
|
| selectattr('edge', 'in', group_names)
|
||||||
|
| selectattr('domaine', 'defined')
|
||||||
|
| selectattr('domaine', 'equalto', domaine_interne | default(''))
|
||||||
|
| map(attribute='fqdn') | list)
|
||||||
|
+ (client_pki_expositions
|
||||||
|
| selectattr('fqdn', 'defined')
|
||||||
|
| selectattr('resout_vers', 'defined')
|
||||||
|
| selectattr('resout_vers', 'equalto', 'service')
|
||||||
|
| selectattr('hote', 'equalto', inventory_hostname)
|
||||||
| map(attribute='fqdn') | list))
|
| map(attribute='fqdn') | list))
|
||||||
| select | unique | list }}
|
| select | unique | list }}
|
||||||
|
|
||||||
|
|
@ -67,21 +85,6 @@ client_pki_provisioner_password: "{{ vault_step_ca_provisioner_password | defaul
|
||||||
# Depot apt officiel Smallstep (partage avec serveur_step_ca).
|
# Depot apt officiel Smallstep (partage avec serveur_step_ca).
|
||||||
client_pki_depot_cle_url: "{{ client_pki_depot_schema }}://packages.smallstep.com/keys/apt/repo-signing-key.gpg"
|
client_pki_depot_cle_url: "{{ client_pki_depot_schema }}://packages.smallstep.com/keys/apt/repo-signing-key.gpg"
|
||||||
client_pki_depot_cle_fichier: "/etc/apt/keyrings/smallstep.asc"
|
client_pki_depot_cle_fichier: "/etc/apt/keyrings/smallstep.asc"
|
||||||
|
|
||||||
# LA MEME CLE QUE `serveur_step_ca` POSE, et c'est le DEPOT qui la porte : les deux
|
|
||||||
# fichiers ont le meme SHA-256. La chercher ici d'abord evite de sortir sur un CDN tiers
|
|
||||||
# pour des octets qu'on possede.
|
|
||||||
#
|
|
||||||
# CE N'EST PAS THEORIQUE POUR UNE FLOTTE D'AMORCAGE. Elle n'a par definition ni serveur
|
|
||||||
# d'artefacts ni cache, donc `client_pki_depot_schema` reste `https` et chaque hote sort
|
|
||||||
# EN DIRECT ; le telechargement echouait alors trois amorcages sur quatre, avec cinq
|
|
||||||
# reprises, sur une bordure amont que rien ici ne controle.
|
|
||||||
#
|
|
||||||
# Le chemin traverse `serveur_step_ca` plutot que de dupliquer le fichier : deux copies
|
|
||||||
# d'une meme cle divergent le jour ou l'amont la remplace, et rien ne dirait laquelle
|
|
||||||
# fait foi.
|
|
||||||
client_pki_depot_cle_locale: >-
|
|
||||||
{{ role_path }}/../serveur_step_ca/files/smallstep-repo-signing-key.asc
|
|
||||||
client_pki_depot_uri: "{{ client_pki_depot_schema }}://packages.smallstep.com/stable/debian"
|
client_pki_depot_uri: "{{ client_pki_depot_schema }}://packages.smallstep.com/stable/debian"
|
||||||
client_pki_depot_suite: "debs"
|
client_pki_depot_suite: "debs"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
# contraire. La sonde compare donc les deux quand un service consomme le certificat.
|
# contraire. La sonde compare donc les deux quand un service consomme le certificat.
|
||||||
sondes:
|
sondes:
|
||||||
- nom: certificat
|
- nom: certificat
|
||||||
|
metier: socle
|
||||||
# 5400 s = une heure et demie, soit six fois la periode du porteur (15 min). Cinq
|
# 5400 s = une heure et demie, soit six fois la periode du porteur (15 min). Cinq
|
||||||
# passages peuvent manquer avant qu'Icinga ne perime : on ne crie pas pour un hoquet,
|
# passages peuvent manquer avant qu'Icinga ne perime : on ne crie pas pour un hoquet,
|
||||||
# on crie pour un silence.
|
# on crie pour un silence.
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,17 @@
|
||||||
# L'empreinte du root CA est la SOURCE DE VÉRITÉ de l'autorité elle-même : on la
|
# L'empreinte du root CA est la SOURCE DE VÉRITÉ de l'autorité elle-même : on la
|
||||||
# dérive à chaud (robuste au from-zero — une AC régénérée a une empreinte neuve).
|
# dérive à chaud (robuste au from-zero — une AC régénérée a une empreinte neuve).
|
||||||
# `client_pki_ca_fingerprint_override` permet d'épingler explicitement si besoin.
|
# `client_pki_ca_fingerprint_override` permet d'épingler explicitement si besoin.
|
||||||
|
#
|
||||||
|
# UNE FOIS POUR TOUTE LA FLOTTE (`run_once`, 2026-09-30). L'empreinte est la meme pour
|
||||||
|
# chaque hote : la deriver par hote lancait TREIZE modules Python en parallele sur l'AC —
|
||||||
|
# 765 Mo, 1 vCPU. A la reconstruction de Technolibre, le noyau a tue le module de
|
||||||
|
# `collab-01` (rc=137) et l'agent de journaux au passage. Avec `run_once`, le resultat
|
||||||
|
# enregistre vaut pour tous les hotes du play.
|
||||||
- name: Dériver l'empreinte du root CA depuis l'autorité
|
- name: Dériver l'empreinte du root CA depuis l'autorité
|
||||||
ansible.builtin.command:
|
ansible.builtin.command:
|
||||||
cmd: "step certificate fingerprint {{ serveur_step_ca_steppath | default('/etc/step-ca') }}/certs/root_ca.crt"
|
cmd: "step certificate fingerprint {{ serveur_step_ca_steppath | default('/etc/step-ca') }}/certs/root_ca.crt"
|
||||||
delegate_to: "{{ groups['serveur_step_ca'][0] }}"
|
delegate_to: "{{ groups['serveur_step_ca'][0] }}"
|
||||||
|
run_once: true
|
||||||
changed_when: false
|
changed_when: false
|
||||||
check_mode: false
|
check_mode: false
|
||||||
register: client_pki_fingerprint_ac
|
register: client_pki_fingerprint_ac
|
||||||
|
|
@ -103,36 +110,8 @@
|
||||||
- telecharger_la_cle_de_signature_smallste_present.stat.exists
|
- telecharger_la_cle_de_signature_smallste_present.stat.exists
|
||||||
- (telecharger_la_cle_de_signature_smallste_present.stat.size | default(0)) == 0
|
- (telecharger_la_cle_de_signature_smallste_present.stat.size | default(0)) == 0
|
||||||
|
|
||||||
- name: Voir si le depot porte la cle de signature Smallstep
|
|
||||||
ansible.builtin.stat:
|
|
||||||
path: "{{ client_pki_depot_cle_locale }}"
|
|
||||||
delegate_to: localhost
|
|
||||||
become: false
|
|
||||||
changed_when: false
|
|
||||||
check_mode: false
|
|
||||||
register: client_pki_cle_depot_locale
|
|
||||||
|
|
||||||
# LE DEPOT D'ABORD, LE RESEAU ENSUITE. `serveur_step_ca` pose deja cette cle depuis ses
|
|
||||||
# `files/` sans rien demander a personne ; le client la telechargeait. Les deux fichiers
|
|
||||||
# sont identiques — meme SHA-256 — si bien que la sortie n'achetait rien et exposait
|
|
||||||
# chaque amorcage a une bordure tierce.
|
|
||||||
- name: Poser la cle de signature Smallstep depuis le depot
|
|
||||||
ansible.builtin.copy:
|
|
||||||
src: "{{ client_pki_depot_cle_locale }}"
|
|
||||||
dest: "{{ client_pki_depot_cle_fichier }}"
|
|
||||||
owner: root
|
|
||||||
group: root
|
|
||||||
mode: "0644"
|
|
||||||
when:
|
|
||||||
- not (telecharger_la_cle_de_signature_smallste_present.stat.exists and telecharger_la_cle_de_signature_smallste_present.stat.size | default(0) > 0)
|
|
||||||
- client_pki_cle_depot_locale.stat.exists
|
|
||||||
|
|
||||||
# LE REPLI RESTE, et il n'est pas mort : un depot qui ne porterait plus ce fichier doit
|
|
||||||
# encore pouvoir amorcer. Ce qui change est l'ORDRE — on ne sort que faute de mieux.
|
|
||||||
- name: Telecharger la cle de signature Smallstep
|
- name: Telecharger la cle de signature Smallstep
|
||||||
when:
|
when: not (telecharger_la_cle_de_signature_smallste_present.stat.exists and telecharger_la_cle_de_signature_smallste_present.stat.size | default(0) > 0)
|
||||||
- not (telecharger_la_cle_de_signature_smallste_present.stat.exists and telecharger_la_cle_de_signature_smallste_present.stat.size | default(0) > 0)
|
|
||||||
- not client_pki_cle_depot_locale.stat.exists
|
|
||||||
# `force` PARCE QU'UNE REPRISE CONDITIONNELLE NE REPREND RIEN (2026-09-12).
|
# `force` PARCE QU'UNE REPRISE CONDITIONNELLE NE REPREND RIEN (2026-09-12).
|
||||||
#
|
#
|
||||||
# Sans lui, `get_url` envoie un `If-Modified-Since` des que le fichier existe. Le cache
|
# Sans lui, `get_url` envoie un `If-Modified-Since` des que le fichier existe. Le cache
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,11 @@ client_sante_unites_tolerees: []
|
||||||
# Six passages du porteur.
|
# Six passages du porteur.
|
||||||
client_sante_ttl_sondes: 5400
|
client_sante_ttl_sondes: 5400
|
||||||
|
|
||||||
|
# LES SONDES A LA MINUTE (2026-09-30) — voir `setops-sante.sh.j2`, mode `minute`. Le `ttl`
|
||||||
|
# couvre trois rapports : une minute manquee ne crie pas, trois oui.
|
||||||
|
client_sante_repertoire_minute: /usr/local/lib/setops/sondes-minute
|
||||||
|
client_sante_ttl_minute: 180
|
||||||
|
|
||||||
# Delai de garde de CHAQUE sonde. Une sonde est cense repondre vite ; celle qui depasse
|
# Delai de garde de CHAQUE sonde. Une sonde est cense repondre vite ; celle qui depasse
|
||||||
# n'a pas de verdict, et on le dit (INCONNU) plutot que de figer tout le rapport.
|
# n'a pas de verdict, et on le dit (INCONNU) plutot que de figer tout le rapport.
|
||||||
client_sante_delai_sonde: 20
|
client_sante_delai_sonde: 20
|
||||||
|
|
@ -69,3 +74,15 @@ client_sante_paquets:
|
||||||
# machines ; ces seuils laissent donc voir une croissance longtemps avant la panne.
|
# machines ; ces seuils laissent donc voir une croissance longtemps avant la panne.
|
||||||
client_sante_disque_avert: 80
|
client_sante_disque_avert: 80
|
||||||
client_sante_disque_crit: 90
|
client_sante_disque_crit: 90
|
||||||
|
|
||||||
|
# Le plancher que la sonde « plancher » compare a l'autoritatif. Mise en defaut PAR
|
||||||
|
# PARAMETRE : un fichier vide ou absent — la sonde doit alors voir tous les noms manquer.
|
||||||
|
client_sante_plancher_fichier: "/etc/hosts"
|
||||||
|
|
||||||
|
# LES UNITES QUE CE ROLE POSE — la liste que `tasks/retirer.yml` defait sur un hote sorti du
|
||||||
|
# groupe. Ecrite a cote des gabarits qu'elle nomme : une unite ajoutee au role s'ajoute ici.
|
||||||
|
client_sante_unites_posees:
|
||||||
|
- setops-sante.timer
|
||||||
|
- setops-sante.service
|
||||||
|
- setops-sondes-minute.timer
|
||||||
|
- setops-sondes-minute.service
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,12 @@
|
||||||
- name: Recharger systemd
|
- name: Recharger systemd
|
||||||
ansible.builtin.systemd:
|
ansible.builtin.systemd:
|
||||||
daemon_reload: true
|
daemon_reload: true
|
||||||
|
|
||||||
|
# UNE UNITE RETIREE PEUT RESTER « failed » DANS LA MEMOIRE DE systemd, et c'est cet etat
|
||||||
|
# que lit `node_exporter`. Recharger ne l'efface pas : il faut `reset-failed`, faute de
|
||||||
|
# quoi le retrait se verrait sur disque et pas dans les metriques (`tasks/retirer.yml`).
|
||||||
|
- name: Oublier l'echec du porteur retire
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
systemctl reset-failed setops-sante.service setops-sondes-minute.service 2>/dev/null || true
|
||||||
|
systemctl daemon-reload
|
||||||
|
changed_when: true
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,20 @@
|
||||||
# script, et P64 veut, a juste titre, que la declaration suive le role qui depose.
|
# script, et P64 veut, a juste titre, que la declaration suive le role qui depose.
|
||||||
sondes:
|
sondes:
|
||||||
- nom: disque
|
- nom: disque
|
||||||
|
metier: socle
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
raison: >-
|
raison: >-
|
||||||
Reste-t-il de la place, et des inodes, sur chaque systeme de fichiers ? Seuls le
|
Reste-t-il de la place, et des inodes, sur chaque systeme de fichiers ? Seuls le
|
||||||
cache apt et les depots de sauvegarde etaient surveilles : un disque plein arretait
|
cache apt et les depots de sauvegarde etaient surveilles : un disque plein arretait
|
||||||
la base ou le courriel sans prevenir.
|
la base ou le courriel sans prevenir.
|
||||||
|
- nom: plancher
|
||||||
|
metier: socle
|
||||||
|
ttl: 5400
|
||||||
|
# Posee sous la condition du plancher (voir `setops-sondes.conf.j2`, 2026-09-28).
|
||||||
|
seulement_si: hosts_statiques_actif
|
||||||
|
defini_par: hosts_statiques
|
||||||
|
raison: >-
|
||||||
|
Le plancher /etc/hosts de la machine dit-il la meme chose que l'autoritatif ? Les deux
|
||||||
|
suivent le plan, chacun quand son role est rejoue. Le 2026-09-29, la console manquait
|
||||||
|
aux planchers des deux locataires : depuis l'edge, elle ne se resolvait pas, et le
|
||||||
|
plancher y est la seule resolution des noms internes.
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,14 @@
|
||||||
|
|
||||||
- name: Assurer le répertoire des secrets Set-OPS
|
- name: Assurer le répertoire des secrets Set-OPS
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
|
# 0700, COMME `client_backup` ET `serveur_backup` (2026-10-01) : ce repertoire tient des
|
||||||
|
# secrets, et tous ses lecteurs tournent en root. Ce role etait seul a l'ouvrir en 0755,
|
||||||
|
# et le dernier deploye gagnait. Garde : test_repertoires_partages.py.
|
||||||
path: /etc/setops
|
path: /etc/setops
|
||||||
state: directory
|
state: directory
|
||||||
owner: root
|
owner: root
|
||||||
group: root
|
group: root
|
||||||
mode: "0755"
|
mode: "0700"
|
||||||
|
|
||||||
- name: Déposer le mot de passe d'API Icinga
|
- name: Déposer le mot de passe d'API Icinga
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
|
|
@ -163,9 +166,31 @@
|
||||||
group: root
|
group: root
|
||||||
mode: "0750"
|
mode: "0750"
|
||||||
|
|
||||||
|
# LA SONDE « plancher » (2026-09-29), sous la condition meme du plancher : sans plancher
|
||||||
|
# gere (`hosts_statiques_actif: false`), il n'y a rien a comparer. Posee ici pour la raison
|
||||||
|
# de « disque » : ce role tourne sur tout hote qui rapporte, sans toucher au systeme.
|
||||||
|
- name: Deposer la sonde du plancher de resolution
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: sonde-plancher.sh.j2
|
||||||
|
dest: /usr/local/lib/setops/sondes/plancher.sh
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0750"
|
||||||
|
when: hosts_statiques_actif | default(true) | bool
|
||||||
|
|
||||||
|
- name: Assurer le repertoire des sondes a la minute
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ client_sante_repertoire_minute }}"
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
- name: Relever les sondes reellement posees
|
- name: Relever les sondes reellement posees
|
||||||
ansible.builtin.find:
|
ansible.builtin.find:
|
||||||
paths: "{{ client_sante_repertoire_sondes | default('/usr/local/lib/setops/sondes') }}"
|
paths:
|
||||||
|
- "{{ client_sante_repertoire_sondes | default('/usr/local/lib/setops/sondes') }}"
|
||||||
|
- "{{ client_sante_repertoire_minute }}"
|
||||||
patterns: "*.sh"
|
patterns: "*.sh"
|
||||||
register: client_sante_posees
|
register: client_sante_posees
|
||||||
|
|
||||||
|
|
@ -211,11 +236,33 @@
|
||||||
state: started
|
state: started
|
||||||
daemon_reload: true
|
daemon_reload: true
|
||||||
|
|
||||||
|
- name: Déployer l'unité et le minuteur des sondes a la minute
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: "{{ item }}.j2"
|
||||||
|
dest: "/etc/systemd/system/{{ item }}"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
loop:
|
||||||
|
- setops-sondes-minute.service
|
||||||
|
- setops-sondes-minute.timer
|
||||||
|
notify: Recharger systemd
|
||||||
|
|
||||||
|
- name: Activer les sondes a la minute
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: setops-sondes-minute.timer
|
||||||
|
enabled: true
|
||||||
|
state: started
|
||||||
|
daemon_reload: true
|
||||||
|
# En simulation, l'unite n'a pas ete posee : l'activer echouerait sans rien dire du reel.
|
||||||
|
when: not ansible_check_mode
|
||||||
|
|
||||||
# PREMIER RAPPORT IMMEDIAT, et ce n'est pas une commodite : sans lui, le service
|
# PREMIER RAPPORT IMMEDIAT, et ce n'est pas une commodite : sans lui, le service
|
||||||
# reste « en attente » jusqu'au premier declenchement, et un deploiement qui vient
|
# reste « en attente » jusqu'au premier declenchement, et un deploiement qui vient
|
||||||
# d'installer la supervision laisserait un tableau vide qu'on prendrait pour un
|
# d'installer la supervision laisserait un tableau vide qu'on prendrait pour un
|
||||||
# tableau sain.
|
# tableau sain.
|
||||||
- name: Rapporter tout de suite (ne pas laisser un tableau vide passer pour sain)
|
- name: Rapporter tout de suite (ne pas laisser un tableau vide passer pour sain)
|
||||||
ansible.builtin.command: /usr/local/sbin/setops-sante.sh
|
ansible.builtin.command: "/usr/local/sbin/setops-sante.sh {{ item }}"
|
||||||
|
loop: ["", "minute"]
|
||||||
changed_when: false
|
changed_when: false
|
||||||
failed_when: false
|
failed_when: false
|
||||||
|
|
|
||||||
48
roles/client_sante/tasks/retirer.yml
Normal file
48
roles/client_sante/tasks/retirer.yml
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
---
|
||||||
|
# DEFAIRE CE QUE CE ROLE A POSE, SUR UN HOTE QUI N'EST PLUS DANS SON GROUPE (2026-10-03).
|
||||||
|
#
|
||||||
|
# Le cas qui l'a fait ecrire : les trois hyperviseurs du site. Ils ont recu `client_sante`
|
||||||
|
# le 2026-09-10, puis en sont sortis le jour meme — la route par defaut gelee (D-57)
|
||||||
|
# empeche un hyperviseur de joindre Icinga, et leurs metriques sont depuis TIREES par
|
||||||
|
# Prometheus (voir `scripts/site_inventaire.py`). Sortis du groupe, plus aucun play ne les
|
||||||
|
# touchait : le minuteur est reste, pointe sur `10.0.36.11`, l'adresse de `site-mon-01`
|
||||||
|
# d'avant la renumerotation.
|
||||||
|
#
|
||||||
|
# Mesure du 2026-10-03 : un echec toutes les 15 min depuis au moins sept jours, environ
|
||||||
|
# 2 050 par hyperviseur. Et `node_exporter` publiait sur chacun UNE unite en echec
|
||||||
|
# permanente — exactement ce que ce role refuse ailleurs (`client_sante_unites_tolerees`) :
|
||||||
|
# le jour ou une vraie unite tombe, le compte passe de 1 a 2 et personne ne le voit.
|
||||||
|
#
|
||||||
|
# ON NE RETIRE QUE CE QUE CE ROLE POSE. Les sondes de `client_journal` et de
|
||||||
|
# `client_metrique` restent dans `/usr/local/lib/setops/sondes/` : ces roles s'appliquent
|
||||||
|
# encore, ils les redeposeraient au prochain passage, et sans porteur elles sont inertes.
|
||||||
|
- name: Les unites du porteur sont-elles encore la ?
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: "/etc/systemd/system/{{ item }}"
|
||||||
|
loop: "{{ client_sante_unites_posees }}"
|
||||||
|
register: client_sante_unites_restantes
|
||||||
|
|
||||||
|
- name: Arreter et desactiver les minuteurs restes en place
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: "{{ item.item }}"
|
||||||
|
state: stopped
|
||||||
|
enabled: false
|
||||||
|
loop: "{{ client_sante_unites_restantes.results }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.item }}"
|
||||||
|
when:
|
||||||
|
- item.stat.exists
|
||||||
|
- item.item is search('\.timer$')
|
||||||
|
|
||||||
|
- name: Retirer les unites, le script et les secrets du rapport passif
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: absent
|
||||||
|
loop: >-
|
||||||
|
{{ client_sante_unites_posees | map('regex_replace', '^', '/etc/systemd/system/') | list
|
||||||
|
+ ['/usr/local/sbin/setops-sante.sh',
|
||||||
|
'/etc/setops/icinga-api.pass',
|
||||||
|
client_sante_ca_verification,
|
||||||
|
'/usr/local/lib/setops/sondes/disque.sh',
|
||||||
|
'/usr/local/lib/setops/sondes/plancher.sh'] }}
|
||||||
|
notify: Oublier l'echec du porteur retire
|
||||||
|
|
@ -109,9 +109,9 @@ echecs=("${restantes[@]}")
|
||||||
# sortirait en 127 et Icinga recevrait un code hors bornes — ou rien. On rapporte donc
|
# sortirait en 127 et Icinga recevrait un code hors bornes — ou rien. On rapporte donc
|
||||||
# CRITIQUE, en le disant : le pire serait qu'un script mort produise un service muet, que
|
# CRITIQUE, en le disant : le pire serait qu'un script mort produise un service muet, que
|
||||||
# le `ttl` finirait par perimer sans que personne sache pourquoi.
|
# le `ttl` finirait par perimer sans que personne sache pourquoi.
|
||||||
sondes() {
|
sondes() { # $1=repertoire $2=ttl des resultats
|
||||||
local f nom sortie code
|
local f nom sortie code
|
||||||
for f in /usr/local/lib/setops/sondes/*.sh; do
|
for f in "$1"/*.sh; do
|
||||||
[[ -x "$f" ]] || continue
|
[[ -x "$f" ]] || continue
|
||||||
nom=$(basename "$f" .sh)
|
nom=$(basename "$f" .sh)
|
||||||
# DELAI DE GARDE SUR CHAQUE SONDE — mesure du 2026-09-09.
|
# DELAI DE GARDE SUR CHAQUE SONDE — mesure du 2026-09-09.
|
||||||
|
|
@ -125,21 +125,31 @@ sondes() {
|
||||||
# INCONNU (3) en le disant, plutot que de laisser le porteur mourir en silence.
|
# INCONNU (3) en le disant, plutot que de laisser le porteur mourir en silence.
|
||||||
sortie=$(timeout {{ client_sante_delai_sonde }} "$f" 2>&1); code=$?
|
sortie=$(timeout {{ client_sante_delai_sonde }} "$f" 2>&1); code=$?
|
||||||
if (( code == 124 )); then
|
if (( code == 124 )); then
|
||||||
rapporter "$nom" 3 "Sonde ${nom} sans reponse apres {{ client_sante_delai_sonde }} s — aucun verdict." "{{ client_sante_ttl_sondes }}"
|
rapporter "$nom" 3 "Sonde ${nom} sans reponse apres {{ client_sante_delai_sonde }} s — aucun verdict." "$2"
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
if (( code > 3 )); then
|
if (( code > 3 )); then
|
||||||
rapporter "$nom" 2 "Sonde ${nom} en erreur (code ${code}) : ${sortie:0:160}" "{{ client_sante_ttl_sondes }}"
|
rapporter "$nom" 2 "Sonde ${nom} en erreur (code ${code}) : ${sortie:0:160}" "$2"
|
||||||
else
|
else
|
||||||
rapporter "$nom" "$code" "${sortie:-(aucun message)}" "{{ client_sante_ttl_sondes }}"
|
rapporter "$nom" "$code" "${sortie:-(aucun message)}" "$2"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# LE MODE MINUTE (2026-09-30). Certaines sondes doivent battre a la minute — `connectivite`,
|
||||||
|
# qui dit qu'un flux est coupe des qu'il l'est. Elles vivent dans leur propre repertoire,
|
||||||
|
# HORS de celui-ci : sinon ce rapport-ci les rejouerait au quart d'heure avec un `ttl` de
|
||||||
|
# 90 min, et leur silence ne se verrait plus avant une heure et demie. Meme rapporteur, meme
|
||||||
|
# compte, aucun second script a tenir aligne.
|
||||||
|
if [[ "${1:-}" == "minute" ]]; then
|
||||||
|
sondes "{{ client_sante_repertoire_minute }}" "{{ client_sante_ttl_minute }}"
|
||||||
|
exit $(( ECHECS_RAPPORT > 0 ? 1 : 0 ))
|
||||||
|
fi
|
||||||
|
|
||||||
n=${#echecs[@]}
|
n=${#echecs[@]}
|
||||||
if [[ $n -eq 0 ]]; then
|
if [[ $n -eq 0 ]]; then
|
||||||
rapporter "sante" 0 "Aucune unite systemd en echec." "${TTL}"
|
rapporter "sante" 0 "Aucune unite systemd en echec." "${TTL}"
|
||||||
sondes
|
sondes /usr/local/lib/setops/sondes "{{ client_sante_ttl_sondes }}"
|
||||||
exit $(( ECHECS_RAPPORT > 0 ? 1 : 0 ))
|
exit $(( ECHECS_RAPPORT > 0 ? 1 : 0 ))
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -147,5 +157,5 @@ fi
|
||||||
# probleme, soit du bruit qu'il faut retirer : dans les deux cas il faut agir. Un seuil
|
# probleme, soit du bruit qu'il faut retirer : dans les deux cas il faut agir. Un seuil
|
||||||
# ferait vivre le bruit indefiniment, et c'est exactement ce qu'on vient de corriger.
|
# ferait vivre le bruit indefiniment, et c'est exactement ce qu'on vient de corriger.
|
||||||
rapporter "sante" 2 "$n unite(s) systemd en echec : ${echecs[*]}" "${TTL}"
|
rapporter "sante" 2 "$n unite(s) systemd en echec : ${echecs[*]}" "${TTL}"
|
||||||
sondes
|
sondes /usr/local/lib/setops/sondes "{{ client_sante_ttl_sondes }}"
|
||||||
exit $(( ECHECS_RAPPORT > 0 ? 1 : 0 ))
|
exit $(( ECHECS_RAPPORT > 0 ? 1 : 0 ))
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
# GENERE par Set-OPS (role client_sante).
|
||||||
|
[Unit]
|
||||||
|
Description=Rapporter a Icinga les sondes a la minute (connectivite...)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/sbin/setops-sante.sh minute
|
||||||
14
roles/client_sante/templates/setops-sondes-minute.timer.j2
Normal file
14
roles/client_sante/templates/setops-sondes-minute.timer.j2
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# GENERE par Set-OPS (role client_sante).
|
||||||
|
[Unit]
|
||||||
|
Description=Sondes a la minute (connectivite...)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=1min
|
||||||
|
OnUnitActiveSec=60s
|
||||||
|
AccuracySec=5s
|
||||||
|
# Dispersion courte : toute la flotte ne frappe pas l'API a la meme seconde, et le
|
||||||
|
# rapport reste bien en deca du `ttl` (3 min).
|
||||||
|
RandomizedDelaySec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
96
roles/client_sante/templates/sonde-plancher.sh.j2
Normal file
96
roles/client_sante/templates/sonde-plancher.sh.j2
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# GENERE par Set-OPS (role client_sante). Ne pas editer a la main.
|
||||||
|
#
|
||||||
|
# SONDE « plancher » — le plancher /etc/hosts de cette machine dit-il la meme chose que
|
||||||
|
# l'autoritatif du domaine ?
|
||||||
|
#
|
||||||
|
# POURQUOI (2026-09-29). Le plancher et la zone PowerDNS derivent du meme plan, mais
|
||||||
|
# chacun ne le suit que quand son role est rejoue. La console a ete ajoutee au plan : la
|
||||||
|
# zone l'a recue, les planchers non — sauf celui d'`ops-01`, redeploye depuis. Depuis
|
||||||
|
# l'edge, `console` ne se resolvait pas, et rien ne le signalait. Sur ces machines, le
|
||||||
|
# plancher est la SEULE resolution des noms internes : un nom qui y manque n'existe pas.
|
||||||
|
#
|
||||||
|
# COMMENT, SANS TRANSFERT DE ZONE. La zone publie `_plancher.<domaine> TXT "n=… sha256=…"`
|
||||||
|
# : le nombre et l'empreinte des paires « nom adresse » que le plancher doit porter pour ce
|
||||||
|
# domaine. On calcule la meme chose ici sur /etc/hosts. Si elles different, on nomme ce
|
||||||
|
# qu'on peut : chaque nom du plancher est demande a l'autoritatif (en trop, readresse), et
|
||||||
|
# l'ecart de nombre dit combien de noms publies manquent ici.
|
||||||
|
#
|
||||||
|
# L'autoritatif est interroge DIRECTEMENT : le resolveur qu'utilisent ces machines peut
|
||||||
|
# ignorer ce domaine (c'est le cas chez les locataires, qui passent par celui du site).
|
||||||
|
#
|
||||||
|
# Mise en defaut PAR PARAMETRE : `client_sante_plancher_fichier` (un plancher vide).
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
DOMAINE='{{ domaine_interne | default('') }}'
|
||||||
|
AUTORITE='{{ hostvars[groups['serveur_powerdns'][0]].ansible_host | default('') if (groups.get('serveur_powerdns') or []) else '' }}'
|
||||||
|
FICHIER='{{ client_sante_plancher_fichier }}'
|
||||||
|
|
||||||
|
if [[ -z "${DOMAINE}" || -z "${AUTORITE}" ]]; then
|
||||||
|
echo "Pas d'autoritatif interne dans cet ecosysteme : rien a quoi comparer le plancher."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
txt=$(dig +short +time=3 +tries=2 TXT "_plancher.${DOMAINE}" "@${AUTORITE}" 2>/dev/null | tr -d '"')
|
||||||
|
if [[ "${txt}" != n=*" sha256="* ]]; then
|
||||||
|
echo "L'autoritatif (${AUTORITE}) ne publie pas l'empreinte du plancher (_plancher.${DOMAINE} : ${txt:-rien}) — zone anterieure, ou injoignable : comparaison impossible."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Les paires « nom adresse » du plancher pour CE domaine : toute ligne, tout nom qui finit
|
||||||
|
# par `.<domaine>` (le nom court des hotes, en alias, n'est pas compte ; la zone non plus).
|
||||||
|
local_=$(python3 - "${FICHIER}" "${DOMAINE}" <<'PY'
|
||||||
|
import hashlib, sys
|
||||||
|
fichier, domaine = sys.argv[1], sys.argv[2]
|
||||||
|
paires = set()
|
||||||
|
try:
|
||||||
|
lignes = open(fichier, encoding="utf-8").read().splitlines()
|
||||||
|
except OSError:
|
||||||
|
lignes = []
|
||||||
|
for l in lignes:
|
||||||
|
champs = l.split("#", 1)[0].split()
|
||||||
|
if len(champs) < 2:
|
||||||
|
continue
|
||||||
|
for nom in champs[1:]:
|
||||||
|
if nom.endswith("." + domaine):
|
||||||
|
paires.add(f"{nom} {champs[0]}")
|
||||||
|
tri = sorted(paires)
|
||||||
|
print(f"n={len(tri)} sha256={hashlib.sha256(chr(10).join(tri).encode()).hexdigest()}")
|
||||||
|
for p in tri:
|
||||||
|
print(p)
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
empreinte=$(printf '%s\n' "${local_}" | head -1)
|
||||||
|
n_pub=${txt#n=}; n_pub=${n_pub%% *}
|
||||||
|
n_loc=${empreinte#n=}; n_loc=${n_loc%% *}
|
||||||
|
perf="| noms=${n_loc};;;0; publies=${n_pub};;;0;"
|
||||||
|
|
||||||
|
if [[ "${empreinte}" == "${txt}" ]]; then
|
||||||
|
echo "Plancher conforme a l'autoritatif : ${n_loc} nom(s) de ${DOMAINE}, memes adresses. ${perf}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ILS DIFFERENT : on nomme ce qu'on peut. Chaque nom du plancher est demande a l'autoritatif.
|
||||||
|
# Un nom qu'il ignore est en trop ici (ou une machine planifiee, que la zone ne publie pas
|
||||||
|
# encore) ; une autre adresse est un readressage que l'un des deux n'a pas suivi.
|
||||||
|
en_trop=(); readresses=(); connus=0; i=0
|
||||||
|
while read -r nom ip; do
|
||||||
|
(( i++ >= 40 )) && break
|
||||||
|
pub=$(dig +short +time=2 +tries=1 A "${nom}" "@${AUTORITE}" 2>/dev/null | tail -1)
|
||||||
|
if [[ -z "${pub}" ]]; then en_trop+=("${nom%%.*}")
|
||||||
|
elif [[ "${pub}" != "${ip}" ]]; then readresses+=("${nom%%.*}=${ip}/${pub}"); connus=$((connus + 1))
|
||||||
|
else connus=$((connus + 1)); fi
|
||||||
|
done < <(printf '%s\n' "${local_}" | tail -n +2)
|
||||||
|
manquants=$(( n_pub - connus ))
|
||||||
|
|
||||||
|
msg="Le plancher de cette machine et l'autoritatif divergent (${n_loc} nom(s) ici, ${n_pub} publie(s))"
|
||||||
|
[[ ${manquants} -gt 0 ]] && msg+=" ; ${manquants} nom(s) publie(s) absent(s) d'ici"
|
||||||
|
[[ -n "${readresses[*]}" ]] && msg+=" ; autre adresse : ${readresses[*]}"
|
||||||
|
[[ -n "${en_trop[*]}" ]] && msg+=" ; inconnu(s) de l'autoritatif : ${en_trop[*]}"
|
||||||
|
msg+=". Rejouer hosts_statiques (ou serveur_powerdns, si c'est la zone qui date)."
|
||||||
|
# Un nom qui MANQUE ou une adresse qui differe casse une resolution : critique. Des noms en
|
||||||
|
# trop seulement (une machine retiree du plan) ne cassent rien : avertissement.
|
||||||
|
if (( manquants > 0 )) || [[ -n "${readresses[*]}" ]]; then
|
||||||
|
echo "${msg} ${perf}"; exit 2
|
||||||
|
fi
|
||||||
|
echo "${msg} ${perf}"; exit 1
|
||||||
|
|
@ -6,6 +6,29 @@
|
||||||
#
|
#
|
||||||
# Attendre ici plutot que seulement avant le playbook : le verrou peut etre repris
|
# Attendre ici plutot que seulement avant le playbook : le verrou peut etre repris
|
||||||
# ENTRE deux taches, ce qu'une verification ponctuelle en amont ne peut pas empecher.
|
# ENTRE deux taches, ce qu'une verification ponctuelle en amont ne peut pas empecher.
|
||||||
|
# APT-GET ATTEND LE VERROU, LUI AUSSI (2026-09-30).
|
||||||
|
#
|
||||||
|
# `lock_timeout` ne protege que la verification faite par le MODULE apt. Pour une mise a
|
||||||
|
# niveau, le module lance ensuite `apt-get dist-upgrade` — qui, lui, n'attend pas : APT 3.0
|
||||||
|
# ne donne d'attente (120 s) qu'a la commande `apt` (`Binary::apt::DPkg::Lock::Timeout`),
|
||||||
|
# pas a `apt-get`. Entre la verification et l'appel, `unattended-upgrades` a repris le
|
||||||
|
# verrou sur `mon-01`, ne une minute plus tot, et la reconstruction de Technolibre s'est
|
||||||
|
# arretee :
|
||||||
|
#
|
||||||
|
# E: Could not get lock /var/lib/dpkg/lock-frontend. It is held by process 1198 (unattended-upgr)
|
||||||
|
#
|
||||||
|
# On le dit donc a APT LUI-MEME, pour tout appel : c'est un fichier, pas une operation apt,
|
||||||
|
# il peut passer en tout premier.
|
||||||
|
- name: Faire attendre le verrou dpkg a tout appel d'APT (apt-get compris)
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/apt/apt.conf.d/10setops-verrou
|
||||||
|
content: |
|
||||||
|
// Gere par Set-OPS (role common_packages). Voir tasks/main.yml.
|
||||||
|
DPkg::Lock::Timeout "{{ common_packages_lock_timeout }}";
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
|
||||||
# DÉSARMER AVANT TOUTE OPÉRATION APT, ET NON APRÈS.
|
# DÉSARMER AVANT TOUTE OPÉRATION APT, ET NON APRÈS.
|
||||||
#
|
#
|
||||||
# Une tâche apt placée avant celle-ci attendrait le verrou que celle-ci doit justement
|
# Une tâche apt placée avant celle-ci attendrait le verrou que celle-ci doit justement
|
||||||
|
|
@ -87,12 +110,29 @@
|
||||||
when: not (common_packages_desarmer_maj_auto | bool)
|
when: not (common_packages_desarmer_maj_auto | bool)
|
||||||
failed_when: false # une unite absente sur cette image n'est pas une faute
|
failed_when: false # une unite absente sur cette image n'est pas une faute
|
||||||
|
|
||||||
|
# AVANT TOUTE OPERATION APT : pas de rattrapage herite du gabarit (voir minuteurs.yml).
|
||||||
|
- name: Empecher le rattrapage des minuteurs apt herite du gabarit
|
||||||
|
ansible.builtin.include_tasks: minuteurs.yml
|
||||||
|
when: not (common_packages_desarmer_maj_auto | bool)
|
||||||
|
|
||||||
- name: Mettre à jour le cache APT
|
- name: Mettre à jour le cache APT
|
||||||
ansible.builtin.apt:
|
ansible.builtin.apt:
|
||||||
update_cache: true
|
update_cache: true
|
||||||
cache_valid_time: 3600
|
cache_valid_time: 3600
|
||||||
lock_timeout: "{{ common_packages_lock_timeout }}"
|
lock_timeout: "{{ common_packages_lock_timeout }}"
|
||||||
|
|
||||||
|
# LES ÉPINGLES AVANT LA MISE À JOUR (2026-10-03). `upgrade: full` montait les paquets
|
||||||
|
# tiers au-dela de `paquets-tiers.yml` : au site, `icinga-php-thirdparty` etait passe de
|
||||||
|
# 1.0.0 (pose par le cache) a 1.0.1 sans que personne l'ait decide. Les preferences apt de
|
||||||
|
# TOUTES les epingles sont posees ici, sur toute machine du socle : une epingle sur un
|
||||||
|
# paquet absent ne fait rien, et fixe la version de sa premiere installation.
|
||||||
|
- name: Épingler tous les paquets tiers avant la mise à jour
|
||||||
|
ansible.builtin.include_role:
|
||||||
|
name: paquets_tiers
|
||||||
|
tasks_from: epingles.yml
|
||||||
|
vars:
|
||||||
|
paquets_tiers_toutes_epingles: true
|
||||||
|
|
||||||
- name: Appliquer les mises à jour disponibles
|
- name: Appliquer les mises à jour disponibles
|
||||||
ansible.builtin.apt:
|
ansible.builtin.apt:
|
||||||
upgrade: full
|
upgrade: full
|
||||||
|
|
@ -124,11 +164,16 @@
|
||||||
|
|
||||||
- name: Assurer le repertoire d etat des sondes
|
- name: Assurer le repertoire d etat des sondes
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
|
# 0755, COMME PARTOUT AILLEURS (2026-10-01) : quatre roles tenaient ce repertoire, trois
|
||||||
|
# en 0750 et `serveur_artefacts` en 0755 — apt-cacher-ng doit le traverser pour servir
|
||||||
|
# le depot de binaires du site. Le dernier deploye gagnait : au site, le 2026-10-01, il
|
||||||
|
# rendait 403, et chaque runner allait chercher 500 Mo sur Internet. Le repertoire ne
|
||||||
|
# tient que des etats de sondes, deja lisibles. Garde : test_repertoires_partages.py.
|
||||||
path: /var/lib/setops
|
path: /var/lib/setops
|
||||||
state: directory
|
state: directory
|
||||||
owner: root
|
owner: root
|
||||||
group: root
|
group: root
|
||||||
mode: "0750"
|
mode: "0755"
|
||||||
|
|
||||||
- name: Deposer la sonde des correctifs de securite
|
- name: Deposer la sonde des correctifs de securite
|
||||||
ansible.builtin.template:
|
ansible.builtin.template:
|
||||||
|
|
|
||||||
65
roles/common_packages/tasks/minuteurs.yml
Normal file
65
roles/common_packages/tasks/minuteurs.yml
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
---
|
||||||
|
# LE RATTRAPAGE HERITE DU GABARIT (2026-10-08).
|
||||||
|
#
|
||||||
|
# `apt-daily.timer` et `apt-daily-upgrade.timer` sont PERSISTANTS : systemd garde la date
|
||||||
|
# de leur dernier passage (`/var/lib/systemd/timers/stamp-*`) et rattrape un passage
|
||||||
|
# manque. Une VM clonee herite de ces traces — celle d'`apt-daily` datait du 2026-06-21,
|
||||||
|
# jour de fabrication du gabarit. Elle « rattrape » donc des son demarrage, avec un delai
|
||||||
|
# aleatoire jusqu'a 60 min : a la reconstruction de Technolibre, `apt-daily-upgrade` est
|
||||||
|
# parti cinq minutes apres la naissance d'`infra-pki-01`, en pleine serie d'operations apt
|
||||||
|
# du role. Il tenait le verrou du CACHE DES ARCHIVES, qu'`apt-get` n'attend jamais
|
||||||
|
# (`DPkg::Lock::Timeout` ne couvre que dpkg) :
|
||||||
|
#
|
||||||
|
# 'apt-get autoremove' failed: E: Could not get lock /var/cache/apt/archives/lock.
|
||||||
|
#
|
||||||
|
# Une trace plus ancienne que la naissance de la machine n'est pas la sienne : on la remet a
|
||||||
|
# l'heure, et on relance le minuteur, qui recalcule son prochain passage (le matin suivant)
|
||||||
|
# sans rattraper. Les mises a jour automatiques restent armees (decision du 2026-09-11) ;
|
||||||
|
# seul disparait un passage herite. Sur une machine en service, la trace est recente :
|
||||||
|
# rien ne change.
|
||||||
|
- name: Naissance de la machine (cle d'hote SSH, posee au premier demarrage)
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: /etc/ssh/ssh_host_ed25519_key.pub
|
||||||
|
register: common_packages_naissance
|
||||||
|
|
||||||
|
- name: Traces des minuteurs apt persistants
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: "/var/lib/systemd/timers/stamp-{{ item }}"
|
||||||
|
loop: "{{ common_packages_unites_maj_auto | select('match', '.*[.]timer$') | list }}"
|
||||||
|
register: common_packages_traces
|
||||||
|
|
||||||
|
- name: Remettre a l'heure une trace heritee du gabarit
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item.stat.path }}"
|
||||||
|
state: touch
|
||||||
|
mode: "0644"
|
||||||
|
loop: >-
|
||||||
|
{{ common_packages_traces.results | selectattr('stat.exists')
|
||||||
|
| selectattr('stat.mtime', 'lt', common_packages_naissance.stat.mtime | default(0))
|
||||||
|
| list }}
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.item }}"
|
||||||
|
register: common_packages_traces_remises
|
||||||
|
when: common_packages_naissance.stat.exists
|
||||||
|
|
||||||
|
# Relancer recalcule le prochain passage d'apres la trace neuve. Relancer un minuteur ne
|
||||||
|
# declenche pas sa cible.
|
||||||
|
- name: Relancer les minuteurs dont la trace a ete remise
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
name: "{{ item.item.item }}"
|
||||||
|
state: restarted
|
||||||
|
loop: "{{ common_packages_traces_remises.results | default([]) | selectattr('changed') | list }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.item.item }}"
|
||||||
|
|
||||||
|
# UN PASSAGE DEJA PARTI se termine avant nos operations apt, plutot que de se les disputer.
|
||||||
|
# `is-active` rend 0 si l'une au moins des unites tourne.
|
||||||
|
- name: Attendre la fin d'un passage apt automatique deja en cours
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv: ["systemctl", "is-active", "--quiet", "apt-daily.service", "apt-daily-upgrade.service"]
|
||||||
|
register: common_packages_passage
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
until: common_packages_passage.rc != 0
|
||||||
|
retries: 30
|
||||||
|
delay: 10
|
||||||
|
|
@ -19,10 +19,12 @@ ff02::2 ip6-allrouters
|
||||||
{% if hosts_statiques_publier_expositions | default(false) and hosts_statiques_expositions %}
|
{% if hosts_statiques_publier_expositions | default(false) and hosts_statiques_expositions %}
|
||||||
|
|
||||||
# --- Alias d'exposition (FQDN publics → l'edge qui les sert) ---
|
# --- Alias d'exposition (FQDN publics → l'edge qui les sert) ---
|
||||||
|
{# `resout_vers: service` — entre machines on va au service, l'edge sert aux personnes
|
||||||
|
(voir `expositions_des_applications`). La zone PowerDNS suit la MEME regle. #}
|
||||||
{% for expo in hosts_statiques_expositions %}
|
{% for expo in hosts_statiques_expositions %}
|
||||||
{% set edge_hosts = groups.get(expo.edge, []) %}
|
{% set cibles = [expo.hote] if expo.resout_vers | default('edge') == 'service' else groups.get(expo.edge, []) %}
|
||||||
{% if edge_hosts and hostvars[edge_hosts[0]].ansible_host is defined %}
|
{% if cibles and cibles[0] in hostvars and hostvars[cibles[0]].ansible_host is defined %}
|
||||||
{{ "%-15s" | format(hostvars[edge_hosts[0]].ansible_host) }} {{ expo.fqdn }}
|
{{ "%-15s" | format(hostvars[cibles[0]].ansible_host) }} {{ expo.fqdn }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -13,3 +13,16 @@ nftables_baseline_extra_udp_ports: []
|
||||||
# source (ip saddr = moindre privilège), dérivées des meta/flux.yml de l'hôte. S'il
|
# source (ip saddr = moindre privilège), dérivées des meta/flux.yml de l'hôte. S'il
|
||||||
# est présent sur le contrôleur, il PRIME sur le gabarit plat ci-dessus.
|
# est présent sur le contrôleur, il PRIME sur le gabarit plat ci-dessus.
|
||||||
nftables_baseline_ruleset_genere: "{{ inventory_dir }}/../../flux-genere/{{ inventory_hostname }}.nft"
|
nftables_baseline_ruleset_genere: "{{ inventory_dir }}/../../flux-genere/{{ inventory_hostname }}.nft"
|
||||||
|
|
||||||
|
# La liste des flux que la sonde `connectivite` teste chaque minute, generee par `make flux`
|
||||||
|
# a cote du ruleset, depuis les MEMES regles (voir templates/sonde-connectivite.sh.j2).
|
||||||
|
nftables_baseline_connectivite_genere: "{{ inventory_dir }}/../../flux-genere/{{ inventory_hostname }}.connectivite.json"
|
||||||
|
|
||||||
|
# LA PASSERELLE N'EST PAS UNE PORTE (2026-10-05). Ports qui ne doivent JAMAIS repondre sur la
|
||||||
|
# passerelle par defaut de la machine — la sonde `connectivite` le verifie chaque minute.
|
||||||
|
# Chez un locataire en SDN, la passerelle de zone est l'HYPERVISEUR lui-meme, dans le VRF du
|
||||||
|
# locataire : une API Proxmox qui y repondrait contournerait la frontiere. Fermee aujourd'hui
|
||||||
|
# (mesure du 2026-10-05 : ECONNREFUSED) ; la garde dit si elle s'ouvre, quelle qu'en soit la
|
||||||
|
# cause (`net.ipv4.tcp_l3mdev_accept`, un pare-feu d'hote retire...). Sur une machine du site,
|
||||||
|
# la passerelle est la frontiere, qui n'ecoute pas sur ces ports.
|
||||||
|
nftables_baseline_connectivite_passerelle_interdits: [8006]
|
||||||
|
|
|
||||||
|
|
@ -80,3 +80,49 @@
|
||||||
state: stopped
|
state: stopped
|
||||||
when: not (nftables_baseline_enabled | bool)
|
when: not (nftables_baseline_enabled | bool)
|
||||||
failed_when: false
|
failed_when: false
|
||||||
|
|
||||||
|
# --- LA SONDE « connectivite » (2026-09-30) ------------------------------------------------
|
||||||
|
#
|
||||||
|
# Ce que ce pare-feu promet, verifie chaque minute depuis la machine qui doit pouvoir
|
||||||
|
# joindre — et non plus seulement au moment d'une activation. Deposee dans le repertoire
|
||||||
|
# des sondes A LA MINUTE, que le porteur `client_sante` joue en mode `minute`.
|
||||||
|
- name: La liste des flux de cet hote est-elle generee ?
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: "{{ nftables_baseline_connectivite_genere }}"
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
changed_when: false
|
||||||
|
register: nftables_baseline_connectivite_stat
|
||||||
|
|
||||||
|
- name: Assurer le repertoire des sondes a la minute
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /usr/local/lib/setops/sondes-minute
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
# PAS DANS /etc/setops : cette liste n'a rien de secret, et ce repertoire-la — celui des
|
||||||
|
# secrets — a deja ses gardiens (0700 chez les locataires, 0755 au site). Un troisieme avis
|
||||||
|
# sur ses droits le faisait basculer a chaque passage (vu en simulation au site).
|
||||||
|
- name: Deposer la liste des flux a tester
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: "{{ nftables_baseline_connectivite_genere }}"
|
||||||
|
dest: /usr/local/lib/setops/connectivite.json
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
when: nftables_baseline_connectivite_stat.stat.exists
|
||||||
|
|
||||||
|
- name: Retirer l'ancien emplacement de la liste (premier deploiement, 2026-09-30)
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /etc/setops/connectivite.json
|
||||||
|
state: absent
|
||||||
|
|
||||||
|
- name: Deposer la sonde « connectivite »
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: sonde-connectivite.sh.j2
|
||||||
|
dest: /usr/local/lib/setops/sondes-minute/connectivite.sh
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0750"
|
||||||
|
|
|
||||||
|
|
@ -7,25 +7,25 @@ table inet setops_filter {
|
||||||
chain input {
|
chain input {
|
||||||
type filter hook input priority 0; policy {{ nftables_baseline_input_policy }};
|
type filter hook input priority 0; policy {{ nftables_baseline_input_policy }};
|
||||||
|
|
||||||
iif "lo" accept
|
iif "lo" accept comment "socle: communications locales"
|
||||||
ct state established,related accept
|
ct state established,related accept comment "socle: connexions etablies et associees"
|
||||||
ct state invalid drop
|
ct state invalid drop comment "socle: paquets invalides"
|
||||||
|
|
||||||
{% if nftables_baseline_allow_icmp | bool %}
|
{% if nftables_baseline_allow_icmp | bool %}
|
||||||
ip protocol icmp accept
|
ip protocol icmp accept comment "socle: diagnostic et erreurs reseau IPv4"
|
||||||
ip6 nexthdr icmpv6 accept
|
ip6 nexthdr icmpv6 accept comment "socle: diagnostic et erreurs reseau IPv6"
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if nftables_baseline_allow_ssh | bool %}
|
{% if nftables_baseline_allow_ssh | bool %}
|
||||||
tcp dport {{ nftables_baseline_ssh_port }} accept
|
tcp dport {{ nftables_baseline_ssh_port }} accept comment "administration: acces SSH du gabarit de repli"
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% for port in nftables_baseline_extra_tcp_ports %}
|
{% for port in nftables_baseline_extra_tcp_ports %}
|
||||||
tcp dport {{ port }} accept
|
tcp dport {{ port }} accept comment "repli: port TCP supplementaire declare par l'instance"
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% for port in nftables_baseline_extra_udp_ports %}
|
{% for port in nftables_baseline_extra_udp_ports %}
|
||||||
udp dport {{ port }} accept
|
udp dport {{ port }} accept comment "repli: port UDP supplementaire declare par l'instance"
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
189
roles/nftables_baseline/templates/sonde-connectivite.sh.j2
Normal file
189
roles/nftables_baseline/templates/sonde-connectivite.sh.j2
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# GENERE par Set-OPS (role nftables_baseline). Ne pas editer a la main.
|
||||||
|
#
|
||||||
|
# SONDE « connectivite » — chaque flux SORTANT que le registre des flux promet a cette
|
||||||
|
# machine passe-t-il, en ce moment ? Rapportee chaque minute (porteur `client_sante`, mode
|
||||||
|
# `minute`) : un flux coupe se voit dans la minute, pas au prochain deploiement.
|
||||||
|
#
|
||||||
|
# POURQUOI (2026-09-30). L'activation du pare-feu Proxmox se prouvait par une matrice rejouee
|
||||||
|
# VM par VM, avant et apres : vingt minutes en fin de reconstruction, et une preuve valable
|
||||||
|
# a l'instant de l'activation seulement. Cette sonde la remplace, et veille ensuite en
|
||||||
|
# permanence.
|
||||||
|
#
|
||||||
|
# LA LISTE n'est pas ecrite ici : `make flux` la derive des MEMES regles resolues que le
|
||||||
|
# `.nft` de chaque hote (`flux-genere/<hote>.connectivite.json`). On teste ce que le pare-feu
|
||||||
|
# applique, pas ce qu'on croit.
|
||||||
|
#
|
||||||
|
# CE QUE L'ERRNO DIT — mesure du 2026-09-30, depuis web-frontal-01 de Technolibre :
|
||||||
|
# ouvert le flux passe -> OK
|
||||||
|
# ECONNREFUSED REFUSE : personne n'ecoute, OU la frontiere refuse -> AVERTISSEMENT
|
||||||
|
# Depuis le 2026-10-01 les interfaces internes de la frontiere rendent un
|
||||||
|
# `reject` — un RST pour TCP, indiscernable d'un port ferme. Et depuis les
|
||||||
|
# `seulement_si`, aucun flux declare ne mene plus a un port sans service :
|
||||||
|
# ce cas n'est plus jamais normal. Jaune, pas rouge — un service qui
|
||||||
|
# redemarre ne doit pas reveiller quelqu'un.
|
||||||
|
# EHOSTUNREACH REJETE (pare-feu Proxmox : icmp host-prohibited), ou
|
||||||
|
# machine injoignable -> CRITIQUE
|
||||||
|
# delai JETE en silence (politique `drop` de nftables) -> CRITIQUE
|
||||||
|
# Un `connect()` TCP ne prouve pas une livraison au-dela d'un relais ; ici les flux sont
|
||||||
|
# directs (meme locataire, routes par la frontiere sans relais applicatif), et c'est bien
|
||||||
|
# le passage des pare-feux qu'on mesure.
|
||||||
|
#
|
||||||
|
# CE QU'ELLE OBSERVE AUSSI : les connexions entrantes ETABLIES que les entrees declarees ne
|
||||||
|
# couvrent pas (AVERTISSEMENT). Un flux reel non declare serait coupe a la prochaine regle
|
||||||
|
# posee : mieux vaut l'apprendre avant.
|
||||||
|
#
|
||||||
|
# CE QU'ELLE GARDE AUSSI (2026-10-05) : la passerelle par defaut ne doit repondre sur aucun
|
||||||
|
# port de `nftables_baseline_connectivite_passerelle_interdits` (l'API Proxmox, 8006). Chez un
|
||||||
|
# locataire en SDN, cette passerelle est l'hyperviseur, dans le VRF du locataire ; une API qui y
|
||||||
|
# repondrait contournerait la frontiere. Ici, `ouvert` prouve bien quelque chose : entre la
|
||||||
|
# machine et sa passerelle, aucun relais. Ouvert -> CRITIQUE ; refuse, rejete, delai -> attendu.
|
||||||
|
# Passerelle introuvable -> AVERTISSEMENT : une garde qui ne s'exerce pas doit le dire.
|
||||||
|
#
|
||||||
|
# Contrat : une ligne, code 0/1/2 (docs/supervision-conception.md).
|
||||||
|
# `CONNECTIVITE_JSON=<fichier>` impose une autre liste : c'est le controle negatif.
|
||||||
|
# `CONNECTIVITE_PASSERELLE=<adresse>` vise une autre « passerelle » : le controle negatif de la
|
||||||
|
# garde (une adresse dont un port interdit repond doit la faire crier).
|
||||||
|
exec python3 - "${CONNECTIVITE_JSON:-/usr/local/lib/setops/connectivite.json}" <<'PY'
|
||||||
|
import concurrent.futures, errno, ipaddress, json, os, socket, subprocess, sys
|
||||||
|
|
||||||
|
INTERDITS = {{ nftables_baseline_connectivite_passerelle_interdits | to_json }}
|
||||||
|
|
||||||
|
try:
|
||||||
|
d = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
print(f"Liste des flux absente ou illisible ({sys.argv[1]}) : rien n'est teste. "
|
||||||
|
"Regenerer `make flux` et redeployer nftables_baseline.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def tcp(ip, port):
|
||||||
|
s = socket.socket()
|
||||||
|
s.settimeout(2)
|
||||||
|
try:
|
||||||
|
s.connect((ip, int(port)))
|
||||||
|
return "ouvert"
|
||||||
|
except socket.timeout:
|
||||||
|
return "delai"
|
||||||
|
except OSError as e:
|
||||||
|
if e.errno == errno.ECONNREFUSED:
|
||||||
|
return "refuse"
|
||||||
|
if e.errno in (errno.EHOSTUNREACH, errno.ENETUNREACH, errno.EACCES, errno.EPERM):
|
||||||
|
return "rejete"
|
||||||
|
return errno.errorcode.get(e.errno, str(e.errno))
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
|
def ping(ip):
|
||||||
|
r = subprocess.run(["ping", "-c1", "-W2", ip], capture_output=True)
|
||||||
|
return "ouvert" if r.returncode == 0 else "delai"
|
||||||
|
|
||||||
|
|
||||||
|
def essai(f):
|
||||||
|
return f, (ping(f["ip"]) if f["proto"] == "icmp" else tcp(f["ip"], f["port"]))
|
||||||
|
|
||||||
|
|
||||||
|
sorties = d.get("sorties") or []
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=16) as ex:
|
||||||
|
resultats = list(ex.map(essai, sorties))
|
||||||
|
|
||||||
|
coupes = [(f, r) for f, r in resultats if r not in ("ouvert", "refuse")]
|
||||||
|
|
||||||
|
# La passerelle n'est pas une porte.
|
||||||
|
def passerelle_par_defaut():
|
||||||
|
if os.environ.get("CONNECTIVITE_PASSERELLE"):
|
||||||
|
return os.environ["CONNECTIVITE_PASSERELLE"]
|
||||||
|
try:
|
||||||
|
for l in subprocess.run(["ip", "-4", "route", "show", "default"], capture_output=True,
|
||||||
|
text=True, timeout=5).stdout.splitlines():
|
||||||
|
c = l.split()
|
||||||
|
if "via" in c:
|
||||||
|
return c[c.index("via") + 1]
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
gw = passerelle_par_defaut()
|
||||||
|
porte = [p for p in INTERDITS if gw and tcp(gw, p) == "ouvert"]
|
||||||
|
personne = [(f, r) for f, r in resultats if r == "refuse"]
|
||||||
|
ouverts = len(resultats) - len(coupes) - len(personne)
|
||||||
|
|
||||||
|
# Entrees etablies hors des regles declarees.
|
||||||
|
def lignes(cmd):
|
||||||
|
try:
|
||||||
|
return subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout.splitlines()
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return []
|
||||||
|
|
||||||
|
ecoute = {l.split()[3].rsplit(":", 1)[1] for l in lignes(["ss", "-Hltn"]) if len(l.split()) >= 4}
|
||||||
|
# MES PROPRES ADRESSES : une machine qui se parle a elle-meme par son adresse (Loki sur
|
||||||
|
# obs-01, port 9096) passe par la boucle locale, jamais par un pare-feu. Vu au premier
|
||||||
|
# deploiement : `obs-01` se declarait « hors des regles » envers lui-meme.
|
||||||
|
moi = set()
|
||||||
|
for l in lignes(["ip", "-o", "addr", "show"]):
|
||||||
|
c = l.split()
|
||||||
|
if len(c) >= 4 and c[2] in ("inet", "inet6"):
|
||||||
|
moi.add(c[3].split("/")[0])
|
||||||
|
reseaux = {}
|
||||||
|
for e in d.get("entrees") or []:
|
||||||
|
if e.get("proto") == "tcp":
|
||||||
|
reseaux.setdefault(str(e["port"]), []).extend(e.get("sources") or [])
|
||||||
|
reseaux.setdefault("22", []).extend(d.get("admin_ssh") or [])
|
||||||
|
hors = set()
|
||||||
|
for l in lignes(["ss", "-Hnt", "state", "established"]):
|
||||||
|
c = l.split()
|
||||||
|
if len(c) < 4:
|
||||||
|
continue
|
||||||
|
lport = c[2].rsplit(":", 1)[1]
|
||||||
|
pair = c[3].rsplit(":", 1)[0].strip("[]").replace("::ffff:", "")
|
||||||
|
if lport not in ecoute:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
adr = ipaddress.ip_address(pair)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if adr.is_loopback or str(adr) in moi:
|
||||||
|
continue
|
||||||
|
permis = False
|
||||||
|
for r in reseaux.get(lport, []):
|
||||||
|
try:
|
||||||
|
if adr in ipaddress.ip_network(str(r), strict=False):
|
||||||
|
permis = True
|
||||||
|
break
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if not permis:
|
||||||
|
hors.add(f"{pair}->{lport}")
|
||||||
|
|
||||||
|
def nomme(xs):
|
||||||
|
return ", ".join(f"{f['vers']}:{f['port'] if f['proto'] != 'icmp' else 'ping'} ({r})" for f, r in xs[:5]) \
|
||||||
|
+ (f" et {len(xs) - 5} autre(s)" if len(xs) > 5 else "")
|
||||||
|
|
||||||
|
perf = (f"| ouverts={ouverts};;;0 coupes={len(coupes)};;;0 sans_service={len(personne)};;;0 "
|
||||||
|
f"hors_regles={len(hors)};;;0 passerelle_ouverte={len(porte)};;;0")
|
||||||
|
if porte:
|
||||||
|
print(f"PASSERELLE OUVERTE : {gw} repond sur {', '.join(map(str, porte))} — la passerelle de zone "
|
||||||
|
f"(l'hyperviseur, chez un locataire) expose ce qui doit rester derriere la frontiere. {perf}")
|
||||||
|
sys.exit(2)
|
||||||
|
if coupes:
|
||||||
|
print(f"COUPE : {len(coupes)}/{len(resultats)} flux sortant(s) ne passent pas — {nomme(coupes)}. {perf}")
|
||||||
|
sys.exit(2)
|
||||||
|
avert = []
|
||||||
|
info = ""
|
||||||
|
if INTERDITS and not gw:
|
||||||
|
avert.append("passerelle par defaut introuvable — la garde de la passerelle ne s'exerce pas")
|
||||||
|
if personne:
|
||||||
|
avert.append(f"{len(personne)} flux refuse(s) — service absent, ou refus de la frontiere — "
|
||||||
|
f"{nomme(personne)}")
|
||||||
|
if hors:
|
||||||
|
avert.append(f"{len(hors)} connexion(s) entrante(s) hors des regles declarees — "
|
||||||
|
+ ", ".join(sorted(hors)[:5]))
|
||||||
|
if avert:
|
||||||
|
print(f"{ouverts}/{len(resultats)} flux sortants ouverts{info} ; " + " ; ".join(avert) + f". {perf}")
|
||||||
|
sys.exit(1)
|
||||||
|
if not resultats:
|
||||||
|
print(f"Aucun flux sortant declare pour {d.get('hote', 'cet hote')} ; aucune entree hors des regles. {perf}")
|
||||||
|
sys.exit(0)
|
||||||
|
print(f"Connectivite : {ouverts}/{len(resultats)} flux sortants ouverts{info} ; aucune entree hors des regles. {perf}")
|
||||||
|
PY
|
||||||
|
|
@ -20,3 +20,19 @@ paquets_tiers_noms: []
|
||||||
# télécharge une fois depuis un poste qui a Internet, on déploie ensuite sans en avoir.
|
# télécharge une fois depuis un poste qui a Internet, on déploie ensuite sans en avoir.
|
||||||
paquets_tiers_cache: "{{ lookup('env', 'HOME') }}/.cache/setops/paquets"
|
paquets_tiers_cache: "{{ lookup('env', 'HOME') }}/.cache/setops/paquets"
|
||||||
paquets_tiers_dest: "/var/cache/setops/paquets"
|
paquets_tiers_dest: "/var/cache/setops/paquets"
|
||||||
|
|
||||||
|
# LES ÉPINGLES S'APPLIQUENT AUSSI SANS CACHE (2026-10-03). Elles ne valaient que pour ce
|
||||||
|
# qui sortait du cache du contrôleur. Un runner — qui n'a pas ce cache — installait la
|
||||||
|
# dernière version publiée, et le `dist-upgrade` du socle (`common_packages`) montait tout
|
||||||
|
# au-delà de l'épingle, cache ou pas. Mesuré le jour même : sept paquets sur vingt et un
|
||||||
|
# plus récents que leur épingle chez les locataires, et un au site.
|
||||||
|
#
|
||||||
|
# Le fichier qui fait foi est celui du dépôt — le même que lit `make cacher-paquets`.
|
||||||
|
paquets_tiers_fichier_epingles: "{{ role_path }}/../../paquets-tiers.yml"
|
||||||
|
# 990 : apt PRÉFÈRE la version épinglée à toute version plus récente (500 par défaut), y
|
||||||
|
# monte un paquet plus ancien, mais ne RÉTROGRADE jamais un paquet déjà plus récent — ce
|
||||||
|
# que ferait 1000 et plus. Éprouvé sur `site-dns-01` et `web-dorsal-01` avant d'écrire.
|
||||||
|
paquets_tiers_priorite: 990
|
||||||
|
# `true` : épingler TOUS les paquets de paquets-tiers.yml, pas seulement `paquets_tiers_noms`.
|
||||||
|
# C'est l'appel du socle, avant son `dist-upgrade` (voir common_packages).
|
||||||
|
paquets_tiers_toutes_epingles: false
|
||||||
|
|
|
||||||
75
roles/paquets_tiers/tasks/epingles.yml
Normal file
75
roles/paquets_tiers/tasks/epingles.yml
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
---
|
||||||
|
# LES ÉPINGLES, POSÉES COMME PRÉFÉRENCES APT — cache ou pas (voir defaults/main.yml).
|
||||||
|
# Une préférence par paquet : plusieurs rôles appellent celui-ci sur la même machine, et
|
||||||
|
# un fichier commun serait réécrit par chacun avec ses seuls paquets.
|
||||||
|
- name: Lire les épingles du dépôt
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
paquets_tiers_toutes: >-
|
||||||
|
{{ (lookup('ansible.builtin.file', paquets_tiers_fichier_epingles) | from_yaml).depots
|
||||||
|
| dict2items | map(attribute='value.paquets') | list | combine }}
|
||||||
|
|
||||||
|
- name: Retenir celles de cet appel (toutes, pour le socle)
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
paquets_tiers_epingles: >-
|
||||||
|
{{ paquets_tiers_toutes if (paquets_tiers_toutes_epingles | bool)
|
||||||
|
else (paquets_tiers_toutes | dict2items | selectattr('key', 'in', paquets_tiers_noms)
|
||||||
|
| items2dict) }}
|
||||||
|
|
||||||
|
- name: Épingler les paquets tiers (préférence apt)
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "/etc/apt/preferences.d/setops-{{ item.key }}"
|
||||||
|
content: |
|
||||||
|
# Géré par Set-OPS (rôle paquets_tiers, depuis paquets-tiers.yml). Ne pas éditer.
|
||||||
|
Package: {{ item.key }}
|
||||||
|
Pin: version {{ item.value }}
|
||||||
|
Pin-Priority: {{ paquets_tiers_priorite }}
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
loop: "{{ paquets_tiers_epingles | dict2items }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.key }}={{ item.value }}"
|
||||||
|
|
||||||
|
# CE QUE L'ÉPINGLE NE PEUT PAS FAIRE, ET QU'IL FAUT DIRE. Deux cas où elle ne tient pas :
|
||||||
|
# - le paquet est DÉJÀ plus récent : 990 ne rétrograde pas (c'est voulu) — l'épingle est
|
||||||
|
# en retard sur la machine, et il faut la relever, pas laisser l'écart dormir ;
|
||||||
|
# - la version épinglée n'est PAS dans l'index du dépôt : apt ignore la préférence sans
|
||||||
|
# un mot et reprend la dernière version — la candidate est alors plus récente.
|
||||||
|
# Ce second contrôle n'a pas cours en simulation : la préférence n'y est pas écrite, et la
|
||||||
|
# candidate lue serait celle d'avant l'épingle — une fausse alerte à chaque `--check`.
|
||||||
|
- name: Comparer installé, candidat et épingle
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
set -uo pipefail
|
||||||
|
{% for nom, version in paquets_tiers_epingles.items() %}
|
||||||
|
inst=$(dpkg-query -W -f='${Version}' {{ nom }} 2>/dev/null || true)
|
||||||
|
cand=$(apt-cache policy {{ nom }} 2>/dev/null | awk '/Candidate:/ {print $2}')
|
||||||
|
if [ -n "$inst" ] && dpkg --compare-versions "$inst" gt "{{ version }}"; then
|
||||||
|
echo "PLUS_RECENT {{ nom }} installe=$inst epingle={{ version }}"
|
||||||
|
fi
|
||||||
|
{% if not ansible_check_mode %}
|
||||||
|
if [ -n "$cand" ] && [ "$cand" != "(none)" ] && dpkg --compare-versions "$cand" gt "{{ version }}" \
|
||||||
|
&& { [ -z "$inst" ] || dpkg --compare-versions "$cand" gt "$inst"; }; then
|
||||||
|
echo "INOPERANTE {{ nom }} candidat=$cand epingle={{ version }}"
|
||||||
|
fi
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
true
|
||||||
|
args:
|
||||||
|
executable: /bin/bash
|
||||||
|
environment:
|
||||||
|
LC_ALL: C
|
||||||
|
register: paquets_tiers_ecarts
|
||||||
|
changed_when: false
|
||||||
|
check_mode: false
|
||||||
|
when: paquets_tiers_epingles | length > 0
|
||||||
|
|
||||||
|
- name: Refuser une épingle qui ne tient pas
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- paquets_tiers_ecarts.stdout_lines | default([]) | length == 0
|
||||||
|
fail_msg: >-
|
||||||
|
Épingle(s) de paquets-tiers.yml qui ne tiennent pas sur {{ inventory_hostname }} :
|
||||||
|
{{ paquets_tiers_ecarts.stdout_lines | default([]) | join(' ; ') }}.
|
||||||
|
PLUS_RECENT : relever l'épingle sur la version installée (puis `make cacher-paquets`).
|
||||||
|
INOPERANTE : la version épinglée n'est pas dans l'index du dépôt — en choisir une qui y est.
|
||||||
|
when: paquets_tiers_epingles | length > 0
|
||||||
|
|
@ -2,6 +2,10 @@
|
||||||
# DÉGRADER, JAMAIS DEVINER. Sans cache sur le contrôleur, ce rôle ne fait rien et le DIT :
|
# DÉGRADER, JAMAIS DEVINER. Sans cache sur le contrôleur, ce rôle ne fait rien et le DIT :
|
||||||
# l'appelant installera depuis le dépôt distant, comme avant. On ne casse pas un
|
# l'appelant installera depuis le dépôt distant, comme avant. On ne casse pas un
|
||||||
# déploiement connecté parce qu'un cache manque.
|
# déploiement connecté parce qu'un cache manque.
|
||||||
|
# LES ÉPINGLES D'ABORD — voir `epingles.yml`, que le socle appelle aussi pour TOUTES.
|
||||||
|
- name: Épingler les paquets demandés (préférences apt)
|
||||||
|
ansible.builtin.include_tasks: epingles.yml
|
||||||
|
|
||||||
- name: Lire le manifeste du cache du contrôleur
|
- name: Lire le manifeste du cache du contrôleur
|
||||||
ansible.builtin.slurp:
|
ansible.builtin.slurp:
|
||||||
src: "{{ paquets_tiers_cache }}/MANIFESTE"
|
src: "{{ paquets_tiers_cache }}/MANIFESTE"
|
||||||
|
|
@ -65,6 +69,13 @@
|
||||||
loop_control:
|
loop_control:
|
||||||
label: "{{ item.key }}"
|
label: "{{ item.key }}"
|
||||||
|
|
||||||
|
# GARDER LES FICHIERS DE CONFIGURATION QUE LES RÔLES ONT MODIFIÉS (2026-10-03). Tant que ce
|
||||||
|
# rôle ne faisait que des PREMIÈRES installations, aucun fichier n'avait été touché. Depuis
|
||||||
|
# que les épingles montent, il MET À JOUR : sur `site-dns-01`, `alloy` 1.19.2 -> 1.20.1 a
|
||||||
|
# demandé quoi faire de `/etc/default/alloy` (le port y est imposé par `client_journal`),
|
||||||
|
# sans personne pour répondre — « fin de fichier sur l'entrée standard », paquet déballé et
|
||||||
|
# NON configuré. Mêmes options que le socle (`common_packages`) : garder la version locale.
|
||||||
|
#
|
||||||
# EN UN SEUL APPEL, ET C'EST LE POINT. `apt` sait résoudre un ENSEMBLE de fichiers locaux
|
# EN UN SEUL APPEL, ET C'EST LE POINT. `apt` sait résoudre un ENSEMBLE de fichiers locaux
|
||||||
# — l'un dépendant de l'autre — alors qu'une installation paquet par paquet échouerait sur
|
# — l'un dépendant de l'autre — alors qu'une installation paquet par paquet échouerait sur
|
||||||
# l'ordre. Icinga en apporte dix-sept qui se dépendent mutuellement : les poser un à un
|
# l'ordre. Icinga en apporte dix-sept qui se dépendent mutuellement : les poser un à un
|
||||||
|
|
@ -74,12 +85,16 @@
|
||||||
- name: Installer les paquets tiers depuis le cache
|
- name: Installer les paquets tiers depuis le cache
|
||||||
ansible.builtin.command:
|
ansible.builtin.command:
|
||||||
argv: >-
|
argv: >-
|
||||||
{{ ["apt-get", "install", "-y", "--no-install-recommends"]
|
{{ ["apt-get", "install", "-y", "--no-install-recommends",
|
||||||
|
"-o", "Dpkg::Options::=--force-confdef", "-o", "Dpkg::Options::=--force-confold"]
|
||||||
+ (paquets_tiers_disponibles.values()
|
+ (paquets_tiers_disponibles.values()
|
||||||
| map('regex_replace', '^', paquets_tiers_dest ~ '/') | list) }}
|
| map('regex_replace', '^', paquets_tiers_dest ~ '/') | list) }}
|
||||||
environment:
|
environment:
|
||||||
DEBIAN_FRONTEND: noninteractive
|
DEBIAN_FRONTEND: noninteractive
|
||||||
register: paquets_tiers_pose
|
register: paquets_tiers_pose
|
||||||
changed_when: "'0 nouvellement installés' not in paquets_tiers_pose.stdout
|
# UNE MISE À JOUR EST UN CHANGEMENT (2026-10-03). La garde ne cherchait que « 0 nouvellement
|
||||||
and '0 newly installed' not in paquets_tiers_pose.stdout"
|
# installés », qu'apt écrit AUSSI quand il met à jour (« 1 mis à jour, 0 nouvellement
|
||||||
|
# installés ») : `alloy` 1.19.2 -> 1.20.1 sur `site-cache-01` s'est rapporté « ok ».
|
||||||
|
changed_when: "'0 mis à jour, 0 nouvellement installés' not in paquets_tiers_pose.stdout
|
||||||
|
and '0 upgraded, 0 newly installed' not in paquets_tiers_pose.stdout"
|
||||||
when: paquets_tiers_disponibles | length > 0
|
when: paquets_tiers_disponibles | length > 0
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ d'annuaire.
|
||||||
## Variables
|
## Variables
|
||||||
| Variable | Défaut | Rôle |
|
| Variable | Défaut | Rôle |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `resoudre_annuaire_hote` | `""` | Dérivé du PLAN (`applications.<app>.hote`) ; surchargeable par instance. Vide et non dérivé, le rôle REFUSE plutôt que de deviner |
|
| `resoudre_annuaire_hote` | `id-ldap-01` | **Le** point où le nom d'hôte est fixé (surchargeable par instance) |
|
||||||
| `resoudre_annuaire_port` | `636` | Port LDAPS |
|
| `resoudre_annuaire_port` | `636` | Port LDAPS |
|
||||||
| `resoudre_annuaire_encryption` | `ldaps` | Schéma de l'URI |
|
| `resoudre_annuaire_encryption` | `ldaps` | Schéma de l'URI |
|
||||||
| `resoudre_annuaire_bind_cn` | `admin` | CN du compte de bind |
|
| `resoudre_annuaire_bind_cn` | `admin` | CN du compte de bind |
|
||||||
|
|
|
||||||
|
|
@ -5,19 +5,7 @@
|
||||||
# aucune machine du plan (l'annuaire vit sur `idm-01`), et Postfix ne pouvait pas se lier.
|
# aucune machine du plan (l'annuaire vit sur `idm-01`), et Postfix ne pouvait pas se lier.
|
||||||
# Constaté le 2026-08-07. Le repli ci-dessous ne sert que si le plan est muet.
|
# Constaté le 2026-08-07. Le repli ci-dessous ne sert que si le plan est muet.
|
||||||
resoudre_annuaire_app: "openldap"
|
resoudre_annuaire_app: "openldap"
|
||||||
# VIDE, ET C'EST LE DEFAUT LUI-MEME QUI ETAIT LE PIEGE. Un nom ecrit ici tient des
|
resoudre_annuaire_hote: "id-ldap-01"
|
||||||
# que le plan se tait : la derivation est sautee, le role bâtit
|
|
||||||
# `ldaps://<ce nom>.<domaine>`, et le consommateur echoue a l'execution sur
|
|
||||||
# « Unable to bind » en nommant une machine qui n'est dans aucun plan.
|
|
||||||
#
|
|
||||||
# La correction qui a introduit la derivation depuis le plan a laisse ce defaut en
|
|
||||||
# place ; il ramene donc l'ancien comportement chaque fois que l'application n'est
|
|
||||||
# pas au plan. Vide, l'assertion qui suit refuse AU DEPLOIEMENT, la ou le nom
|
|
||||||
# devine ne se voyait qu'au premier bind.
|
|
||||||
#
|
|
||||||
# Une instance garde le droit de le poser explicitement : ce qui disparait est la
|
|
||||||
# valeur DEVINEE, pas la surcharge.
|
|
||||||
resoudre_annuaire_hote: ""
|
|
||||||
resoudre_annuaire_port: 636
|
resoudre_annuaire_port: 636
|
||||||
resoudre_annuaire_encryption: "ldaps"
|
resoudre_annuaire_encryption: "ldaps"
|
||||||
# COMPTE DE LIAISON — CHAQUE CONSOMMATEUR A LE SIEN (2026-09-13).
|
# COMPTE DE LIAISON — CHAQUE CONSOMMATEUR A LE SIEN (2026-09-13).
|
||||||
|
|
|
||||||
|
|
@ -37,17 +37,6 @@
|
||||||
resoudre_annuaire_hote: "{{ (applications | default({})).get(resoudre_annuaire_app, {}).get('hote') }}"
|
resoudre_annuaire_hote: "{{ (applications | default({})).get(resoudre_annuaire_app, {}).get('hote') }}"
|
||||||
when: ((applications | default({})).get(resoudre_annuaire_app, {}).get('hote') | default('', true)) | length > 0
|
when: ((applications | default({})).get(resoudre_annuaire_app, {}).get('hote') | default('', true)) | length > 0
|
||||||
|
|
||||||
- name: Exiger que le plan nomme l'hôte de l'annuaire
|
|
||||||
ansible.builtin.assert:
|
|
||||||
that:
|
|
||||||
- resoudre_annuaire_hote | length > 0
|
|
||||||
fail_msg: >-
|
|
||||||
L'application « {{ resoudre_annuaire_app }} » n'est pas au plan, et aucune
|
|
||||||
instance ne pose `resoudre_annuaire_hote` : rien ne nomme l'annuaire. Le rôle
|
|
||||||
qui le demande ne peut pas s'y lier. Un nom deviné ici produirait
|
|
||||||
« Unable to bind » sur une machine qui n'est dans aucun plan — et le
|
|
||||||
déploiement passerait au VERT jusque-là.
|
|
||||||
|
|
||||||
# set_fact (pas les defaults) pour que ces valeurs persistent dans le rôle consommateur
|
# set_fact (pas les defaults) pour que ces valeurs persistent dans le rôle consommateur
|
||||||
# après include_role (les defaults du rôle inclus ne fuient pas hors de son exécution).
|
# après include_role (les defaults du rôle inclus ne fuient pas hors de son exécution).
|
||||||
- name: Dériver la connexion à l'annuaire
|
- name: Dériver la connexion à l'annuaire
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,13 @@ quarante minutes sur la **première** machine.
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **dépôts apt** | ce rôle — un cache, servi à la flotte |
|
| **dépôts apt** | ce rôle — un cache, servi à la flotte |
|
||||||
| **téléchargements directs** (Forgejo, Keycloak, Nextcloud, oauth2-proxy, collections) | le **cache du contrôleur** : il télécharge une fois et pousse par SSH |
|
| **téléchargements directs** (Forgejo, Keycloak, Nextcloud et ses applications, oauth2-proxy) | ce rôle — le **dépôt de binaires** (`/var/lib/setops/artefacts-directs`, servi sous `/setops-binaires` sur le même port) |
|
||||||
|
| **ce que le runner installe** (roues Python, collections Ansible, `verrou-runner.yml`) | ce rôle — le même dépôt de binaires |
|
||||||
|
|
||||||
Deux mécanismes parce que ce sont deux problèmes : on ne sert pas un dépôt apt par `scp`.
|
Les rôles consommateurs prennent ces fichiers au dépôt du site d'abord, vérifient leur somme
|
||||||
|
(ou leur signature), et ne sortent sur Internet qu'en repli, en le disant. Depuis le
|
||||||
|
2026-10-09, une reconstruction ne demande plus rien à PyPI, Galaxy ni à l'app store de
|
||||||
|
Nextcloud quand le dépôt est rempli.
|
||||||
|
|
||||||
## Ce qu'il ne couvre pas encore
|
## Ce qu'il ne couvre pas encore
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -172,5 +172,7 @@ serveur_artefacts_directs:
|
||||||
url: "{{ serveur_keycloak_signature_url }}"
|
url: "{{ serveur_keycloak_signature_url }}"
|
||||||
- nom: "nextcloud-{{ serveur_nextcloud_version }}.tar.bz2"
|
- nom: "nextcloud-{{ serveur_nextcloud_version }}.tar.bz2"
|
||||||
url: "{{ serveur_nextcloud_url }}"
|
url: "{{ serveur_nextcloud_url }}"
|
||||||
|
- nom: "nextcloud-{{ serveur_nextcloud_version }}.tar.bz2.asc"
|
||||||
|
url: "{{ serveur_nextcloud_signature_url }}"
|
||||||
- nom: "oauth2-proxy-{{ serveur_oauth2_proxy_version }}.tar.gz"
|
- nom: "oauth2-proxy-{{ serveur_oauth2_proxy_version }}.tar.gz"
|
||||||
url: "{{ serveur_oauth2_proxy_url }}"
|
url: "{{ serveur_oauth2_proxy_url }}"
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,12 @@
|
||||||
# Supervision derivee du role. Voir docs/supervision-conception.md.
|
# Supervision derivee du role. Voir docs/supervision-conception.md.
|
||||||
sondes:
|
sondes:
|
||||||
- nom: cache-apt
|
- nom: cache-apt
|
||||||
|
metier: paquets
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
raison: Le cache d'artefacts repond-il ? S'il se tait, TOUT apt de l'ecosysteme echoue — on agit dans
|
raison: Le cache d'artefacts repond-il ? S'il se tait, TOUT apt de l'ecosysteme echoue — on agit dans
|
||||||
la minute.
|
la minute.
|
||||||
- nom: cache-apt-volume
|
- nom: cache-apt-volume
|
||||||
|
metier: paquets
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
raison: 'Reste-t-il de la place au cache ? Un cache plein cesse d''ecrire sans se plaindre, et l''echec
|
raison: 'Reste-t-il de la place au cache ? Un cache plein cesse d''ecrire sans se plaindre, et l''echec
|
||||||
apparait chez les clients. Verdict distinct de la disponibilite : il appelle un billet, pas un reveil.'
|
apparait chez les clients. Verdict distinct de la disponibilite : il appelle un billet, pas un reveil.'
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,14 @@
|
||||||
label: "{{ item }}"
|
label: "{{ item }}"
|
||||||
when: serveur_artefacts_directs_actif | bool
|
when: serveur_artefacts_directs_actif | bool
|
||||||
|
|
||||||
|
# CE QUE LE RUNNER INSTALLE (2026-10-09) : ses roues Python et ses collections, verrouillees
|
||||||
|
# a l'octet dans `verrou-runner.yml`, a la racine du moteur. Lues, pas recopiees.
|
||||||
|
- name: Lire le verrou du runner (roues Python, collections Ansible)
|
||||||
|
ansible.builtin.include_vars:
|
||||||
|
file: "{{ role_path }}/../../verrou-runner.yml"
|
||||||
|
name: serveur_artefacts_verrou_runner
|
||||||
|
when: serveur_artefacts_directs_actif | bool
|
||||||
|
|
||||||
# `force: false` ET UN `dest` NOMME PAR LA VERSION : une publication ne change pas sous
|
# `force: false` ET UN `dest` NOMME PAR LA VERSION : une publication ne change pas sous
|
||||||
# le meme nom, donc un fichier deja present est le bon. Une nouvelle version porte un
|
# le meme nom, donc un fichier deja present est le bon. Une nouvelle version porte un
|
||||||
# autre nom et se telecharge d'elle-meme — le depot se met a jour en suivant les roles,
|
# autre nom et se telecharge d'elle-meme — le depot se met a jour en suivant les roles,
|
||||||
|
|
@ -211,10 +219,18 @@
|
||||||
ansible.builtin.get_url:
|
ansible.builtin.get_url:
|
||||||
url: "{{ item.url }}"
|
url: "{{ item.url }}"
|
||||||
dest: "{{ serveur_artefacts_directs_dir }}/{{ item.nom }}"
|
dest: "{{ serveur_artefacts_directs_dir }}/{{ item.nom }}"
|
||||||
|
checksum: "{{ ('sha256:' ~ item.sha256) if item.sha256 is defined else omit }}"
|
||||||
mode: "0644"
|
mode: "0644"
|
||||||
force: false
|
force: false
|
||||||
timeout: 60
|
timeout: 60
|
||||||
loop: "{{ serveur_artefacts_directs }}"
|
# Les applications de Nextcloud (2026-10-08) : declarees par `serveur_nextcloud`, lues
|
||||||
|
# telles quelles — meme `nom`, meme `url`, rien de recopie ici.
|
||||||
|
# Et le verrou du runner (2026-10-09). Un artefact qui declare sa somme est verifie ici :
|
||||||
|
# le site ne tient pas un fichier altere en chemin.
|
||||||
|
loop: >-
|
||||||
|
{{ serveur_artefacts_directs + (serveur_nextcloud_apps | default([]))
|
||||||
|
+ (serveur_artefacts_verrou_runner.python | default([]))
|
||||||
|
+ (serveur_artefacts_verrou_runner.collections | default([])) }}
|
||||||
loop_control:
|
loop_control:
|
||||||
label: "{{ item.nom }}"
|
label: "{{ item.nom }}"
|
||||||
register: serveur_artefacts_directs_tires
|
register: serveur_artefacts_directs_tires
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
# peremption. Le silence alerte autant que l'echec.
|
# peremption. Le silence alerte autant que l'echec.
|
||||||
sondes:
|
sondes:
|
||||||
- nom: depot
|
- nom: depot
|
||||||
|
metier: donnees
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
raison: 'Le depot accepte-t-il encore une ecriture ? Il ne juge pas le contenu — chiffre
|
raison: 'Le depot accepte-t-il encore une ecriture ? Il ne juge pas le contenu — chiffre
|
||||||
cote client, il ne peut pas l''ouvrir. Il constate que l''endroit prend encore : en
|
cote client, il ne peut pas l''ouvrir. Il constate que l''endroit prend encore : en
|
||||||
|
|
|
||||||
|
|
@ -62,9 +62,14 @@ for noeud in {{ serveur_backup_noeuds_attendus | sort | join(' ') }}; do
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Le plus recent de tous les groupes (machine ET liste de chemins), pas le premier : voir
|
||||||
|
# `client_backup/templates/verifier-mon-depot.sh.j2` (2026-10-08).
|
||||||
json=$(restic -r "${depot}" snapshots --latest 1 --json 2>/dev/null)
|
json=$(restic -r "${depot}" snapshots --latest 1 --json 2>/dev/null)
|
||||||
horodatage=$(printf '%s' "${json}" | python3 -c \
|
horodatage=$(printf '%s' "${json}" | python3 -c \
|
||||||
'import sys,json;d=json.load(sys.stdin);print(d[0]["time"] if d else "")' 2>/dev/null)
|
'import sys,json,re,datetime
|
||||||
|
d=json.load(sys.stdin) or []
|
||||||
|
t=lambda x: datetime.datetime.fromisoformat(re.sub(r"Z$","+00:00",re.sub(r"(\.\d{6})\d+",r"\1",x["time"]))).timestamp()
|
||||||
|
print(max(d,key=t)["time"] if d else "")' 2>/dev/null)
|
||||||
if [[ -z "${horodatage}" ]]; then
|
if [[ -z "${horodatage}" ]]; then
|
||||||
rapporter "${noeud}" 2 "AUCUN INSTANTANE dans ${depot} — depot present mais vide."
|
rapporter "${noeud}" 2 "AUCUN INSTANTANE dans ${depot} — depot present mais vide."
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
# peremption. Le silence alerte autant que l'echec.
|
# peremption. Le silence alerte autant que l'echec.
|
||||||
sondes:
|
sondes:
|
||||||
- nom: depot-locataires
|
- nom: depot-locataires
|
||||||
|
metier: sauvegardes_locataires
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
raison: 'Les depots des locataires sont-ils toujours etanches, et reste-t-il de la
|
raison: 'Les depots des locataires sont-ils toujours etanches, et reste-t-il de la
|
||||||
place devant eux ? Un mode qui glisse laisse un locataire lire ou effacer les
|
place devant eux ? Un mode qui glisse laisse un locataire lire ou effacer les
|
||||||
|
|
|
||||||
|
|
@ -42,5 +42,6 @@ flux:
|
||||||
pair: externe
|
pair: externe
|
||||||
chiffrement: tls-requis
|
chiffrement: tls-requis
|
||||||
raison: >-
|
raison: >-
|
||||||
Les dépôts tiers qui n'existent qu'en HTTPS (smallstep, Grafana, Icinga). Le cache
|
Les dépôts tiers qui n'existent qu'en HTTPS (smallstep, Grafana, Icinga), et le
|
||||||
les relaie pour que la flotte n'ait pas à sortir elle-même.
|
dépôt de binaires (roues PyPI, collections Galaxy, versions GitHub, Nextcloud,
|
||||||
|
Forgejo). Le site les relaie pour que la flotte n'ait pas à sortir elle-même.
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
# peremption. Le silence alerte autant que l'echec.
|
# peremption. Le silence alerte autant que l'echec.
|
||||||
sondes:
|
sondes:
|
||||||
- nom: cache-site-racine
|
- nom: cache-site-racine
|
||||||
|
metier: paquets
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
raison: 'Ce cache est-il toujours la RACINE de la chaine, et peut-il encore remplir ?
|
raison: 'Ce cache est-il toujours la RACINE de la chaine, et peut-il encore remplir ?
|
||||||
Un cache chaine sur lui-meme ou coupe de Debian repond parfaitement — il sert ce
|
Un cache chaine sur lui-meme ou coupe de Debian repond parfaitement — il sert ce
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
# peremption. Le silence alerte autant que l'echec.
|
# peremption. Le silence alerte autant que l'echec.
|
||||||
sondes:
|
sondes:
|
||||||
- nom: edition
|
- nom: edition
|
||||||
|
metier: edition
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
raison: 'L''edition en ligne repond-elle ? `/hosting/discovery` est l''adresse que Nextcloud
|
raison: 'L''edition en ligne repond-elle ? `/hosting/discovery` est l''adresse que Nextcloud
|
||||||
interroge lui-meme pour savoir quels documents Collabora sait ouvrir. Sans elle, le
|
interroge lui-meme pour savoir quels documents Collabora sait ouvrir. Sans elle, le
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@
|
||||||
# Surcharge de l'unité du paquet. Le `coolwsd.xml` livré (439 lignes commentées) reste
|
# Surcharge de l'unité du paquet. Le `coolwsd.xml` livré (439 lignes commentées) reste
|
||||||
# intact : le remplacer par un gabarit maison obligerait à suivre son évolution à chaque
|
# intact : le remplacer par un gabarit maison obligerait à suivre son évolution à chaque
|
||||||
# version amont, et ferait perdre les commentaires qui expliquent chaque réglage.
|
# version amont, et ferait perdre les commentaires qui expliquent chaque réglage.
|
||||||
|
#
|
||||||
|
# AUCUNE VÉRIFICATION DE VERSION (mesure du 2026-10-10) : par défaut coolwsd interroge
|
||||||
|
# collabora.co.uk toutes les 10 heures, et le dit aux utilisateurs par une fenêtre. Les
|
||||||
|
# mises à jour arrivent par apt ; `fetch_update_check=0` coupe l'appel, la fenêtre suit.
|
||||||
[Service]
|
[Service]
|
||||||
ExecStart=
|
ExecStart=
|
||||||
ExecStart=/usr/bin/coolwsd --version --o:sys_template_path=/opt/cool/systemplate \
|
ExecStart=/usr/bin/coolwsd --version --o:sys_template_path=/opt/cool/systemplate \
|
||||||
|
|
@ -12,6 +16,8 @@ ExecStart=/usr/bin/coolwsd --version --o:sys_template_path=/opt/cool/systemplate
|
||||||
--o:ssl.termination={{ serveur_collabora_ssl_termination | bool | lower }} \
|
--o:ssl.termination={{ serveur_collabora_ssl_termination | bool | lower }} \
|
||||||
--o:server_name={{ serveur_collabora_server_name }} \
|
--o:server_name={{ serveur_collabora_server_name }} \
|
||||||
--o:storage.wopi.alias_group[0].host=https://{{ serveur_collabora_domaine_nextcloud }}:443 \
|
--o:storage.wopi.alias_group[0].host=https://{{ serveur_collabora_domaine_nextcloud }}:443 \
|
||||||
|
--o:fetch_update_check=0 \
|
||||||
|
--o:allow_update_popup=false \
|
||||||
--o:admin_console.enable={{ serveur_collabora_admin_actif | bool | lower }}{% if serveur_collabora_admin_actif | bool %} \
|
--o:admin_console.enable={{ serveur_collabora_admin_actif | bool | lower }}{% if serveur_collabora_admin_actif | bool %} \
|
||||||
--o:admin_console.username={{ serveur_collabora_admin_utilisateur }} \
|
--o:admin_console.username={{ serveur_collabora_admin_utilisateur }} \
|
||||||
--o:admin_console.password={{ serveur_collabora_admin_mot_de_passe }}{% endif %}
|
--o:admin_console.password={{ serveur_collabora_admin_mot_de_passe }}{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -9,22 +9,39 @@ flux:
|
||||||
chiffrement: ssh
|
chiffrement: ssh
|
||||||
raison: "Plan de gestion : administration et déploiement Ansible par SSH (inter-nœud ; l'accès depuis l'extérieur est filtré à l'OPNsense)."
|
raison: "Plan de gestion : administration et déploiement Ansible par SSH (inter-nœud ; l'accès depuis l'extérieur est filtré à l'OPNsense)."
|
||||||
|
|
||||||
# --- Sortie générale de la flotte ---
|
# --- Sortie apt directe : SEULEMENT SANS AMONT D'ARTEFACTS (2026-10-10) ---
|
||||||
# Portée par le socle parce qu'elle vaut pour TOUS les hôtes, sans exception.
|
#
|
||||||
# Non déclarée, la frontière la bloquerait : son `block out` final est un vrai
|
# C'ÉTAIT UNE SORTIE 80/443 VERS TOUT INTERNET, POUR TOUTE MACHINE. Sa raison, « dépôts
|
||||||
# default-deny, et la flotte perdrait ses mises à jour et son horloge en silence.
|
# apt », n'était plus vraie chez les locataires : leurs 26 machines prennent TOUTES leurs
|
||||||
|
# sources en `http://` par le cache du site (`artefacts_amorcage`), aucune n'a de dépôt
|
||||||
|
# HTTPS. Mesuré à la frontière, elle ne servait plus qu'à ce qu'aucun rôle ne déclarait :
|
||||||
|
# télémétrie de Grafana, d'Alloy et de Loki, vérification de version de Collabora,
|
||||||
|
# cartes de rspamd, repère du génome. Les trois premiers sont coupés à la source ; les
|
||||||
|
# deux derniers ont désormais leur propre flux, dans leur rôle.
|
||||||
|
#
|
||||||
|
# ELLE NE RESTE QUE LÀ OÙ IL N'Y A AUCUN AMONT. Le site non plus ne la reçoit pas : ses
|
||||||
|
# machines visent son propre cache depuis le 2026-09-14 (`site_inventaire.py`,
|
||||||
|
# `artefacts_amorcage`), et le cache, lui, porte ses propres sorties 80/443
|
||||||
|
# (`serveur_cache_site`, `serveur_artefacts`). Seul un écosystème sans aucune source
|
||||||
|
# d'artefacts — un laboratoire isolé — prend encore ses paquets en direct.
|
||||||
- sens: egress
|
- sens: egress
|
||||||
port: 443
|
port: 443
|
||||||
protocole: tcp
|
protocole: tcp
|
||||||
pair: externe
|
pair: externe
|
||||||
chiffrement: tls-requis
|
chiffrement: tls-requis
|
||||||
raison: "Dépôts apt en HTTPS (Debian, Smallstep, Grafana, Icinga, Forgejo, Nextcloud) — sans quoi aucun correctif de sécurité n'entre."
|
seulement_si: {variable: artefacts_amorcage, non_vide: false}
|
||||||
|
raison: >-
|
||||||
|
Dépôts apt en direct, pour un écosystème SANS aucune source d'artefacts. Le site
|
||||||
|
et ses locataires passent par le cache du site et ne la reçoivent pas.
|
||||||
- sens: egress
|
- sens: egress
|
||||||
port: 80
|
port: 80
|
||||||
protocole: tcp
|
protocole: tcp
|
||||||
pair: externe
|
pair: externe
|
||||||
chiffrement: clair
|
chiffrement: clair
|
||||||
raison: "Dépôts apt en clair et redirections HTTP des miroirs (l'intégrité vient de la signature des paquets, pas du transport)."
|
seulement_si: {variable: artefacts_amorcage, non_vide: false}
|
||||||
|
raison: >-
|
||||||
|
Dépôts apt en clair et redirections des miroirs, pour un écosystème SANS amont
|
||||||
|
d'artefacts. L'intégrité vient de la signature des paquets, pas du transport.
|
||||||
# --- Decouverte de MTU de chemin (PMTUD) ---
|
# --- Decouverte de MTU de chemin (PMTUD) ---
|
||||||
# L'overlay EVPN plafonne a 1450 : tout ce qui traverse la frontiere depend de ces
|
# L'overlay EVPN plafonne a 1450 : tout ce qui traverse la frontiere depend de ces
|
||||||
# deux messages. Bloques, la connexion s'etablit, les petites requetes passent et les
|
# deux messages. Bloques, la connexion s'etablit, les petites requetes passent et les
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,14 @@
|
||||||
# correctif publie en exposition acceptee.
|
# correctif publie en exposition acceptee.
|
||||||
sondes:
|
sondes:
|
||||||
- nom: correctifs
|
- nom: correctifs
|
||||||
|
metier: socle
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
raison: >-
|
raison: >-
|
||||||
Des correctifs de securite attendent-ils d'etre appliques, et depuis quand ?
|
Des correctifs de securite attendent-ils d'etre appliques, et depuis quand ?
|
||||||
Set-OPS desarme les mises a jour automatiques et les applique au deploiement :
|
Set-OPS desarme les mises a jour automatiques et les applique au deploiement :
|
||||||
sans cette mesure, rien ne dit quand le geste est du.
|
sans cette mesure, rien ne dit quand le geste est du.
|
||||||
- nom: horloge
|
- nom: horloge
|
||||||
|
metier: socle
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
raison: >-
|
raison: >-
|
||||||
L'heure est-elle disciplinee, et par la SOURCE DECLAREE ? Une machine peut etre
|
L'heure est-elle disciplinee, et par la SOURCE DECLAREE ? Une machine peut etre
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
# Supervision derivee du role. Voir docs/supervision-conception.md.
|
# Supervision derivee du role. Voir docs/supervision-conception.md.
|
||||||
sondes:
|
sondes:
|
||||||
- nom: zones-publiques
|
- nom: zones-publiques
|
||||||
|
metier: noms_publics
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
raison: 'Le serveur public sert-il TOUTES les zones de ses locataires ? Un secondaire qui n''a jamais
|
raison: 'Le serveur public sert-il TOUTES les zones de ses locataires ? Un secondaire qui n''a jamais
|
||||||
recu une zone repond REFUSED : pour Internet, ce domaine n''a plus de serveur de noms, pendant que le
|
recu une zone repond REFUSED : pour Internet, ce domaine n''a plus de serveur de noms, pendant que le
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
# Supervision derivee du role. Voir docs/supervision-conception.md.
|
# Supervision derivee du role. Voir docs/supervision-conception.md.
|
||||||
sondes:
|
sondes:
|
||||||
- nom: boites
|
- nom: boites
|
||||||
|
metier: courriel
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
raison: 'Dovecot repond-il, et sait-il encore RESOUDRE un utilisateur dans l''annuaire ? Un Dovecot
|
raison: 'Dovecot repond-il, et sait-il encore RESOUDRE un utilisateur dans l''annuaire ? Un Dovecot
|
||||||
dont le lien LDAP est tombe accepte les connexions et refuse toutes les authentifications : le service
|
dont le lien LDAP est tombe accepte les connexions et refuse toutes les authentifications : le service
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,41 @@
|
||||||
group: "{{ serveur_dovecot_vmail_utilisateur }}"
|
group: "{{ serveur_dovecot_vmail_utilisateur }}"
|
||||||
mode: "0750"
|
mode: "0750"
|
||||||
|
|
||||||
|
# LES BOITES D'UNE INCARNATION PRECEDENTE (2026-09-30). Remises tant que le repertoire est
|
||||||
|
# vide — avant que la premiere livraison n'y cree quoi que ce soit.
|
||||||
|
- name: Restauration — les boites sont-elles vierges ?
|
||||||
|
ansible.builtin.find:
|
||||||
|
paths: "{{ serveur_dovecot_vmail_base }}"
|
||||||
|
file_type: any
|
||||||
|
register: serveur_dovecot_vmail_initial
|
||||||
|
|
||||||
|
- name: Restauration — serveur_dovecot d'une incarnation precedente ?
|
||||||
|
ansible.builtin.include_role:
|
||||||
|
name: client_backup
|
||||||
|
tasks_from: restaurer.yml
|
||||||
|
vars:
|
||||||
|
client_backup_restaurer_jeu: serveur_dovecot
|
||||||
|
client_backup_restaurer_vierge: "{{ serveur_dovecot_vmail_initial.matched | default(1) == 0 }}"
|
||||||
|
|
||||||
|
- name: Restauration — remettre serveur_dovecot
|
||||||
|
changed_when: true
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv: >-
|
||||||
|
{{ ['/usr/local/sbin/setops-restaurer', 'fichiers',
|
||||||
|
'--instantane', client_backup_restauration.instantane]
|
||||||
|
+ ['--proprietaire', serveur_dovecot_vmail_utilisateur + ':' + serveur_dovecot_vmail_utilisateur]
|
||||||
|
+ client_backup_restauration.chemins }}
|
||||||
|
when: client_backup_restauration.etat == 'a_restaurer'
|
||||||
|
|
||||||
|
- name: Restauration — acter serveur_dovecot
|
||||||
|
ansible.builtin.include_role:
|
||||||
|
name: client_backup
|
||||||
|
tasks_from: acter.yml
|
||||||
|
vars:
|
||||||
|
client_backup_acter_etat: restaure
|
||||||
|
when: client_backup_restauration.etat == 'a_restaurer'
|
||||||
|
|
||||||
|
|
||||||
# --- Pont de certificat step_ca (déposé par client_pki) ---
|
# --- Pont de certificat step_ca (déposé par client_pki) ---
|
||||||
- name: TLS — créer le répertoire des certificats
|
- name: TLS — créer le répertoire des certificats
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
# laisse la meme trace qu'un audit en bonne sante pour qui ne regarde que `systemctl`.
|
# laisse la meme trace qu'un audit en bonne sante pour qui ne regarde que `systemctl`.
|
||||||
sondes:
|
sondes:
|
||||||
- nom: audit
|
- nom: audit
|
||||||
|
metier: socle
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
# Posee par `auditd` seulement si `auditd_enabled` : Icinga ne l'attend qu'a ces
|
# Posee par `auditd` seulement si `auditd_enabled` : Icinga ne l'attend qu'a ces
|
||||||
# conditions (voir `setops-sondes.conf.j2`, 2026-09-28).
|
# conditions (voir `setops-sondes.conf.j2`, 2026-09-28).
|
||||||
|
|
@ -27,3 +28,15 @@ sondes:
|
||||||
La piste d'audit s'ecrit-elle vraiment ? Des regles armees dans le noyau ne
|
La piste d'audit s'ecrit-elle vraiment ? Des regles armees dans le noyau ne
|
||||||
prouvent pas qu'un demon collecte : le 2026-08-30, quinze machines ont eu les
|
prouvent pas qu'un demon collecte : le 2026-08-30, quinze machines ont eu les
|
||||||
deux apparences de la sante sans qu'une seule ligne soit ecrite.
|
deux apparences de la sante sans qu'une seule ligne soit ecrite.
|
||||||
|
- nom: connectivite
|
||||||
|
metier: socle
|
||||||
|
ttl: 180
|
||||||
|
# Attendue a la minute, et non au quart d'heure : c'est tout son interet. Voir le mode
|
||||||
|
# `minute` du porteur (`client_sante`).
|
||||||
|
fraicheur: 180
|
||||||
|
raison: >-
|
||||||
|
Chaque flux que le registre promet a cette machine passe-t-il, maintenant ? Un pare-feu
|
||||||
|
(Proxmox ou nftables) qui coupe un flux declare ne casse rien de visible tout de suite :
|
||||||
|
la sauvegarde echoue cette nuit, le certificat ne se renouvelle pas demain. Teste chaque
|
||||||
|
minute depuis la machine qui doit joindre, avec la cause (rejete, delai, personne
|
||||||
|
n'ecoute) — et les connexions entrantes que les regles declarees ne couvrent pas.
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,10 @@ serveur_forge_site_port: "{{ serveur_forgejo_port | default(443) }}"
|
||||||
# `serveur_ops_depots` nomme des depots sous ce proprietaire, pas ailleurs.
|
# `serveur_ops_depots` nomme des depots sous ce proprietaire, pas ailleurs.
|
||||||
serveur_forge_site_organisation: "genome"
|
serveur_forge_site_organisation: "genome"
|
||||||
|
|
||||||
# COMBIEN DE DEPOTS AU MINIMUM. Un site sert au moins le moteur et sa propre carte ; en
|
# COMBIEN DE DEPOTS PUBLICS AU MINIMUM. La sonde lit l'API SANS identifiant : elle voit ce
|
||||||
# dessous de deux, la forge n'a pas de quoi faire naitre un ecosysteme. On ne fixe pas un
|
# qu'un ecosysteme neuf peut cloner sans rien detenir. Depuis le 2026-10-11, la carte du
|
||||||
# compte exact : le nombre de locataires change, la sonde ne doit pas changer avec.
|
# site et les plans des locataires sont PRIVES (`docs/depots-prives-conception.md`) : il
|
||||||
serveur_forge_site_sonde_depots_minimum: 2
|
# reste au moins le moteur. Les depots prives se surveillent ailleurs — `make genome-etat`,
|
||||||
|
# `make forge-cles`, et chaque runner qui les tire. On ne fixe pas un compte exact : le
|
||||||
|
# nombre de depots change, la sonde ne doit pas changer avec.
|
||||||
|
serveur_forge_site_sonde_depots_minimum: 1
|
||||||
|
|
|
||||||
|
|
@ -39,3 +39,22 @@ flux:
|
||||||
# de le demander : seul le registre distingue « j'ouvre » de « j'autorise une source
|
# de le demander : seul le registre distingue « j'ouvre » de « j'autorise une source
|
||||||
# de plus sur l'écoute d'un autre ».
|
# de plus sur l'écoute d'un autre ».
|
||||||
partage: true
|
partage: true
|
||||||
|
|
||||||
|
# LA LECTURE DE SON DEPOT PAR LE RUNNER D'UN LOCATAIRE (2026-10-11,
|
||||||
|
# `docs/depots-prives-conception.md`). Les depots des locataires deviennent prives : chacun
|
||||||
|
# se lit avec une cle de deploiement en lecture seule, et une cle de deploiement ne passe
|
||||||
|
# que par SSH. `runners_locataires` : les seuls runners, lus dans les faces publiees — pas
|
||||||
|
# les supernets. L'utilisateur `git` de ce `sshd` est contraint par la forge : la cle n'y
|
||||||
|
# ouvre aucune session, seulement le depot qui l'a enregistree.
|
||||||
|
- sens: ingress
|
||||||
|
port: 22
|
||||||
|
protocole: tcp
|
||||||
|
pair: runners_locataires
|
||||||
|
chiffrement: ssh
|
||||||
|
raison: >-
|
||||||
|
Chaque runner de locataire lit son propre depot du genome, prive, avec sa cle de
|
||||||
|
deploiement en lecture seule. Il ne lit ni le depot d'un autre locataire, ni celui
|
||||||
|
du site.
|
||||||
|
# CE ROLE N'OUVRE AUCUNE ECOUTE — il emprunte le `sshd` du socle, comme
|
||||||
|
# `serveur_ops_tenant` (voir P33).
|
||||||
|
partage: true
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue