Compare commits
35 commits
v2026.10.0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 479ec3cc7d | |||
| 9a4b63544e | |||
| 3e2846cc5f | |||
| cca22f47ca | |||
| 4660a8892a | |||
| 6141bc4b2f | |||
| e8907285d5 | |||
| e9215a2141 | |||
| 2cbfa2df9b | |||
| f04e790d2c | |||
| 12caff1cff | |||
| 543791e859 | |||
| 11b5bb5733 | |||
| a204ead8ab | |||
| e7f1a74040 | |||
| 8294e5dc71 | |||
| 4532a98707 | |||
| 40af2d1ec6 | |||
| 2e9b31bef7 | |||
| d0a6002b87 | |||
| 013c1b5279 | |||
| 8c1e8c725b | |||
| 44c24e62e4 | |||
| 2beef04dc6 | |||
| d6591cb271 | |||
| 92a6fb0ead | |||
| ce84d0e1ea | |||
| 56033afd00 | |||
| 3c16ef45cd | |||
| 78c2c25761 | |||
| 3d01208a68 | |||
| 077c6c91b4 | |||
| 8251022148 | |||
| c891d7fb26 | |||
| 42e23885cd |
54 changed files with 6397 additions and 164 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -9,6 +9,9 @@ facts_cache/
|
|||
**/vault.yml
|
||||
# Underlay = fabric physique de l'operateur (cluster-global) ; gabarit public seul.
|
||||
/underlay.yml
|
||||
# Contexte actif de CETTE machine (`site:<depot>` ou `locataire:<depot>`) : propre a chaque
|
||||
# poste et a chaque runner, jamais versionne. Voir scripts/contexte.py.
|
||||
/contexte
|
||||
*.secret
|
||||
*.pem
|
||||
*.key
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ Si `ansible-lint` n’est pas disponible, le signaler clairement. Ne pas invente
|
|||
## Écrire, puis relire (D-68)
|
||||
|
||||
`--syntax-check` et `ansible-lint` prouvent que le dépôt est cohérent **avec lui-même**.
|
||||
C'est aussi ce que font les 83 preuves de `make prouver` : elles lisent le dépôt, sans le
|
||||
C'est aussi ce que font les 94 preuves de `make prouver` : elles lisent le dépôt, sans le
|
||||
moindre appel réseau. **Aucune ne demande au système déployé s'il ressemble à ce que le
|
||||
dépôt annonce.**
|
||||
|
||||
|
|
|
|||
1199
CHANGELOG.md
1199
CHANGELOG.md
File diff suppressed because it is too large
Load diff
66
Makefile
66
Makefile
|
|
@ -317,6 +317,8 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire)
|
|||
python3 scripts/tests/test_inventory_host.py
|
||||
python3 scripts/tests/test_raser.py
|
||||
python3 scripts/tests/test_raser_resultat.py
|
||||
python3 scripts/tests/test_temoins.py
|
||||
python3 scripts/tests/test_genome_colis.py
|
||||
python3 scripts/tests/test_adressage_derive.py
|
||||
python3 scripts/tests/test_gui_intrants.py
|
||||
python3 scripts/tests/test_runbooks.py
|
||||
|
|
@ -333,6 +335,8 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire)
|
|||
python3 scripts/tests/test_restauration.py
|
||||
python3 scripts/tests/test_repertoires_partages.py
|
||||
python3 scripts/tests/test_frontiere_refus.py
|
||||
python3 scripts/tests/test_contexte.py
|
||||
python3 scripts/tests/test_appels_locataire.py
|
||||
|
||||
# LA SONDE QUI RAPPORTE CE QUI DISTINGUE (2026-08-28). Trois faux diagnostics en une
|
||||
# journee, tous dus a l'instrument : `curl` et `bash /dev/tcp` ecrasent « la machine
|
||||
|
|
@ -609,7 +613,7 @@ site: ansible-runtime ## Regenere playbooks/site.yml depuis les couches et le gr
|
|||
site-verifier: ## Verifie que playbooks/site.yml correspond aux couches declarees
|
||||
python3 scripts/orchestrer.py verifier
|
||||
|
||||
.PHONY: flux flux-verifier
|
||||
.PHONY: flux flux-verifier fiches-site-deposer
|
||||
cles-recenser: ## Montre ce qui n'existe QUE sur ce poste (sans rien ecrire)
|
||||
python3 scripts/exporter_cles.py --recenser
|
||||
|
||||
|
|
@ -745,8 +749,8 @@ proxmox-fw-activer-vm: ansible-runtime ## Active le pare-feu d'UNE VM en le prou
|
|||
|
||||
.PHONY: proxmox-fw-eprouver proxmox-fw-activer-vm
|
||||
.PHONY: placement-plan sdn-plan sdn-appliquer
|
||||
placement-plan: ansible-runtime ## Le noeud, stockage, pont et gabarit du tenant existent-ils sur ce cluster ? (aucune ecriture)
|
||||
python3 scripts/devis_placement.py
|
||||
placement-plan: ansible-runtime ## Le noeud, stockage, pont et gabarit du tenant existent-ils sur ce cluster ? (aucune ecriture) [TENANT=<depot>]
|
||||
python3 scripts/devis_placement.py $(if $(TENANT),--locataire $(TENANT))
|
||||
|
||||
sdn-plan: ansible-runtime ## Ecart entre le SDN EVPN (+ sortie des VRF) et son devis (aucune ecriture)
|
||||
python3 scripts/appliquer_sdn.py
|
||||
|
|
@ -869,6 +873,17 @@ site-intrants: ## Les intrants que le SITE monte expose a ses locataires (derive
|
|||
site-intrants-verifier: ## Le locataire monte suit-il encore son site ? (aucune ecriture)
|
||||
python3 scripts/site_intrants.py --verifier
|
||||
|
||||
face-reseau-publier: ## Chaque locataire publie sa face reseau pour son site (a commiter chez lui, apres `make flux`)
|
||||
@# LE LOCATAIRE PUBLIE, LE SITE LIT (2026-10-05). Le site lisait les fichiers internes de ses
|
||||
@# locataires ; il lit desormais `face-reseau.yml`. P92 refuse une face perimee.
|
||||
python3 scripts/contexte.py --publier-faces
|
||||
|
||||
fiches-site-deposer: ## Le site depose chez chacun de ses locataires la fiche qu'il lui destine (a commiter chez eux)
|
||||
@# LE SITE DEPOSE, LE LOCATAIRE LIT (2026-10-05). Le runner d'un locataire n'a pas le depot
|
||||
@# de son site : ce que le site lui attribue et lui offre lui parvient par ce fichier,
|
||||
@# `fiche-site.yml`, que l'instancier lit. P91 refuse une fiche perimee.
|
||||
python3 scripts/contexte.py --deposer-fiches
|
||||
|
||||
.PHONY: vpn-admin-plan vpn-admin-appliquer
|
||||
vpn-admin-plan: ## Acces WireGuard des admins : ce que la frontiere porte face au plan (aucune ecriture)
|
||||
python3 scripts/vpn_admin.py plan
|
||||
|
|
@ -918,7 +933,7 @@ flux-verifier: ## Verifie que le registre des flux correspond aux meta/flux.yml
|
|||
valider: ansible-runtime ## Passe la recette de validation sur la flotte
|
||||
ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/valider.yml
|
||||
|
||||
.PHONY: restauration-etat restauration-renoncer sauvegarder-maintenant
|
||||
.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 \
|
||||
|
|
@ -932,6 +947,15 @@ restauration-etat: ansible-runtime ## Ce qui attend dans le depot, et ce que cha
|
|||
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 \
|
||||
|
|
@ -1126,14 +1150,23 @@ define VAULT_UNE_FOIS
|
|||
endef
|
||||
|
||||
|
||||
flotte-creer: _instance-requise ## Cree les VM manquantes de la flotte depuis le plan — PARALLELE=n
|
||||
# LE SITE MATERIALISE UN LOCATAIRE NOMME, D'APRES SA FACE (2026-10-05, docs/conception-contextes.md,
|
||||
# materialisation M2). Avec `TENANT=<depot>`, la liste des machines et leurs parametres de clonage
|
||||
# viennent de `face-reseau.yml` que le locataire publie — rendus a l'octet pres comme
|
||||
# `parametres-proxmox` (P93) — et aucune instance n'est montee. Sans `TENANT`, l'ancienne lecture
|
||||
# de l'instance montee (le poste, le laboratoire).
|
||||
LISTE_ACTIFS = $(if $(TENANT),python3 scripts/contexte.py --hotes-actifs $(TENANT),python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) lister-actifs)
|
||||
PARAMS_CLONAGE = $(if $(TENANT),python3 scripts/contexte.py --parametres-clonage $(TENANT) --hote,python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) parametres-proxmox --hote)
|
||||
INSTANCE_OU_LOCATAIRE = $(if $(TENANT),,_instance-requise)
|
||||
|
||||
flotte-creer: $(INSTANCE_OU_LOCATAIRE) ## Cree les VM manquantes de la flotte depuis le plan — PARALLELE=n
|
||||
@set -e; \
|
||||
if [[ "$(CONFIRMER)" != "true" ]]; then \
|
||||
printf '%s\n' 'Refus: creation de TOUTES les VM actives du plan (clone Proxmox).'; \
|
||||
printf '%s\n' 'Relancer avec CONFIRMER=true.'; \
|
||||
exit 2; \
|
||||
fi; \
|
||||
hotes="$$(python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) lister-actifs)"; \
|
||||
hotes="$$($(LISTE_ACTIFS))"; \
|
||||
if [[ -z "$$hotes" ]]; then printf '%s\n' 'Refus: aucun hote actif dans le plan.'; exit 2; fi; \
|
||||
par="$(PARALLELE)"; par="$${par:-4}"; \
|
||||
tmp="$$(mktemp -d)"; trap 'rm -rf "$$tmp"' EXIT; \
|
||||
|
|
@ -1142,7 +1175,7 @@ flotte-creer: _instance-requise ## Cree les VM manquantes de la flotte depuis le
|
|||
n=0; \
|
||||
for h in $$hotes; do \
|
||||
printf '\n=== Creation VM: %s ===\n' "$$h"; \
|
||||
( $(MAKE) creer-vm HOTE="$$h" > "$$tmp/$$h.log" 2>&1; printf '%s' "$$?" > "$$tmp/$$h.rc" ) & \
|
||||
( $(MAKE) creer-vm HOTE="$$h" $(if $(TENANT),TENANT="$(TENANT)") > "$$tmp/$$h.log" 2>&1; printf '%s' "$$?" > "$$tmp/$$h.rc" ) & \
|
||||
n=$$((n+1)); \
|
||||
if (( n >= par )); then wait -n || true; n=$$((n-1)); fi; \
|
||||
done; \
|
||||
|
|
@ -1157,6 +1190,13 @@ flotte-creer: _instance-requise ## Cree les VM manquantes de la flotte depuis le
|
|||
if (( echec )); then printf '\nAu moins une VM n a pas ete creee — rien ne continue.\n'; exit 2; fi; \
|
||||
printf '\nToutes les VM actives sont creees.\n'
|
||||
|
||||
locataire-creer: ## Le SITE cree les VM d'un locataire d'apres sa face reseau — TENANT=<depot> CONFIRMER=true [PARALLELE=n]
|
||||
@if [[ -z "$(TENANT)" ]]; then printf '%s\n' 'Refus: TENANT=<depot du locataire> requis.'; exit 2; fi
|
||||
@$(MAKE) --no-print-directory flotte-creer TENANT="$(TENANT)" CONFIRMER="$(CONFIRMER)" PARALLELE="$(PARALLELE)"
|
||||
locataire-raser: ## DESTRUCTIF : le SITE rase les VM d'un locataire d'apres sa face reseau — TENANT=<depot> CONFIRMER=true INSTANCE=<nom court> [HOTE=h]
|
||||
@if [[ -z "$(TENANT)" ]]; then printf '%s\n' 'Refus: TENANT=<depot du locataire> requis.'; exit 2; fi
|
||||
@$(MAKE) --no-print-directory raser TENANT="$(TENANT)" CONFIRMER="$(CONFIRMER)" INSTANCE="$(INSTANCE)" HOTE="$(HOTE)"
|
||||
|
||||
# --- LES MACHINES DU SITE -----------------------------------------------------
|
||||
#
|
||||
# UN SITE N'EST PAS UN PLAN. Un tenant se derive de son `index` ; un site n'en a pas et
|
||||
|
|
@ -1422,8 +1462,8 @@ _attendre-flotte: ansible-runtime
|
|||
printf 'Flotte joignable.\n'
|
||||
|
||||
.PHONY: reconstruire
|
||||
raser: ansible-runtime _instance-requise ## DESTRUCTIF : detruit les VM derivees du plan — exige CONFIRMER=true ET INSTANCE=<nom>
|
||||
@python3 scripts/raser.py $(if $(INSTANCE),--instance $(INSTANCE)) $(if $(HOTE),--hote $(HOTE)) $(if $(filter true,$(CONFIRMER)),--confirmer)
|
||||
raser: ansible-runtime $(INSTANCE_OU_LOCATAIRE) ## DESTRUCTIF : detruit les VM derivees du plan — exige CONFIRMER=true ET INSTANCE=<nom> [TENANT=<depot>]
|
||||
@python3 scripts/raser.py $(if $(TENANT),--locataire $(TENANT)) $(if $(INSTANCE),--instance $(INSTANCE)) $(if $(HOTE),--hote $(HOTE)) $(if $(filter true,$(CONFIRMER)),--confirmer)
|
||||
|
||||
# `make flux` D'ABORD, et ce n'est pas une precaution : sans lui, `instance/flux-genere/`
|
||||
# est VIDE et le socle deploie nftables en `policy drop` SANS AUCUNE REGLE DERIVEE. La
|
||||
|
|
@ -1605,7 +1645,7 @@ cloner-vm: ansible-runtime ## Clone une VM depuis le gabarit dore — HOTE=<nom>
|
|||
-e proxmox_clone_nom="$(HOTE)" \
|
||||
-e proxmox_clone_vmid="$(VMID)" \
|
||||
-e proxmox_clone_ipconfig0="$$ipconfig" \
|
||||
-e proxmox_clone_pool="$(if $(POOL),$(POOL),$$(python3 scripts/devis_proxmox_pools.py --pool-actif))" \
|
||||
-e proxmox_clone_pool="$(if $(POOL),$(POOL),$$(python3 scripts/devis_proxmox_pools.py $(if $(TENANT),--pool-du-locataire $(TENANT),--pool-actif)))" \
|
||||
); \
|
||||
[[ -n "$(VMID_MODELE)" ]] && extra_vars+=( -e proxmox_clone_vmid_modele="$(VMID_MODELE)" ); \
|
||||
[[ -n "$(NOEUD_PROXMOX)" ]] && extra_vars+=( -e proxmox_clone_noeud="$(NOEUD_PROXMOX)" ); \
|
||||
|
|
@ -1648,13 +1688,13 @@ cloner-vm: ansible-runtime ## Clone une VM depuis le gabarit dore — HOTE=<nom>
|
|||
fi; \
|
||||
ansible-playbook -i localhost, $(PLAYBOOK_PROXMOX_CLONER_VM) "$${vault_args[@]}" "$${extra_vars[@]}"
|
||||
|
||||
creer-vm: _instance-requise ## Cree une VM et attend qu'elle soit joignable — HOTE=<nom>
|
||||
creer-vm: $(INSTANCE_OU_LOCATAIRE) ## Cree une VM et attend qu'elle soit joignable — HOTE=<nom> [TENANT=<depot>]
|
||||
@set -e; \
|
||||
if [[ -z "$(HOTE)" ]]; then \
|
||||
printf '%s\n' 'Refus: relancer avec HOTE=nom_hote (declare dans le plan).'; \
|
||||
exit 2; \
|
||||
fi; \
|
||||
params="$$(python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) parametres-proxmox --hote $(HOTE))"; \
|
||||
params="$$($(PARAMS_CLONAGE) $(HOTE))"; \
|
||||
eval "$$params"; \
|
||||
export SETOPS_CLES_AMORCAGE; \
|
||||
$(MAKE) cloner-vm \
|
||||
|
|
@ -1695,7 +1735,7 @@ creer-vm: _instance-requise ## Cree une VM et attend qu'elle soit joignable —
|
|||
@# ATTENDRE LA DISPONIBILITE est un autre geste, deplace chez `deployer` : cloud-init
|
||||
@# et dpkg se guettent juste avant de configurer, par celui qui va configurer.
|
||||
@if [[ "$(ATTENDRE)" != "false" ]]; then \
|
||||
params="$$(python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) parametres-proxmox --hote $(HOTE))"; \
|
||||
params="$$($(PARAMS_CLONAGE) $(HOTE))"; \
|
||||
eval "$$params"; \
|
||||
python3 scripts/attendre_materialisation.py \
|
||||
--vmid "$$SETOPS_VMID" --ip "$$SETOPS_IP" --hote "$(HOTE)" \
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
# Ecrit par `make wiki-publier`, lu par la preuve P60. Ne pas editer a la main.
|
||||
remote: ssh://git@eregion.chezlepro.ca:2222/Alliance-Boreale/Set-OPS-Public.wiki.git
|
||||
source: 3fe7e3c
|
||||
date: 2026-09-29
|
||||
source: e9215a2
|
||||
date: 2026-10-07
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ README de rôles). Cette page comble ces deux trous.
|
|||
|---|---|---|
|
||||
| rôles | 69 | `roles/*/` |
|
||||
| README de rôles | 69 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
|
||||
| documents | 45 | `docs/*.md` |
|
||||
| documents | 46 | `docs/*.md` |
|
||||
| pièces d'audit | 51 | `docs/audit/*` |
|
||||
| unités de wiki | 27 | `wiki/*.md` |
|
||||
| décisions en vigueur | 85 | lignes `\| **D-nn** \|` de `decisions-architecture.md` |
|
||||
|
|
@ -36,7 +36,7 @@ README de rôles). Cette page comble ces deux trous.
|
|||
| Sujet | Documents |
|
||||
|---|---|
|
||||
| **Autorité / gouvernance** | `AGENTS.md` (source d'autorité), `CLAUDE.md`, `docs/MISE-A-JOUR-CODEX-CLAUDE.md` |
|
||||
| **Le modèle (plan)** | `docs/architecture-set-ops.md` (survol) → `docs/plan-et-generation.md` (à fond) → `docs/meta-classe.md` (concept) |
|
||||
| **Le modèle (plan)** | `docs/architecture-set-ops.md` (survol) → `docs/plan-et-generation.md` (à fond) → `docs/meta-classe.md` (concept) → `docs/conception-contextes.md` (site et locataire : deux classes, un contrat) |
|
||||
| **Services, maturité, dette** | `docs/catalogue-services.md` (**la carte de maturité + la cruft y sont déjà**) |
|
||||
| **Exploitation / VM** | `docs/vm-lifecycle.md`, `docs/procedure-template-debian13-proxmox.md`, `docs/config-proxmox.md`, `docs/nomenclature-vm.md`, `docs/multi-instances.md` |
|
||||
| **Conceptions de domaine** | `docs/identite-sso.md`, `docs/courriel-conception.md`, `docs/bindings-conception.md`, `docs/dns-interne.md`, `docs/dimensionnement-ressources.md`, `docs/integrations-vm.md` |
|
||||
|
|
|
|||
277
docs/conception-contextes.md
Normal file
277
docs/conception-contextes.md
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
# Contextes : un tronc commun, deux classes (SITE et LOCATAIRE)
|
||||
|
||||
> **Pour qui :** le **mainteneur** — comment le moteur sait s'il sert un site ou un locataire, et ce que les deux s'apprennent l'un à l'autre.
|
||||
|
||||
> **Statut : arrêtée avec l'exploitant le 2026-10-04.** Rien n'est encore construit ; les
|
||||
> décisions sont au §7, le chemin au §6.
|
||||
|
||||
## 1. Le problème, mesuré
|
||||
|
||||
Le moteur ne sait pas dans quel contexte il tourne : **chaque script le devine**. Relevé du
|
||||
2026-10-04 : **33 scripts** font leur propre déduction, à partir de cinq indices différents.
|
||||
|
||||
| Indice | Ce qu'on en déduit | Scripts |
|
||||
|---|---|---|
|
||||
| lien `instance/` ou `SETOPS_INSTANCE` | « un locataire est monté » | 23 |
|
||||
| lien `underlay.yml` ou `SETOPS_UNDERLAY` | « un site est monté » ; son plan est à côté | 8 |
|
||||
| `SETOPS_INVENTAIRE` | quel inventaire de locataire lire | 8 |
|
||||
| `../*/plan/nomenclature.yml` | « la fédération », les locataires frères | 11 |
|
||||
| `../SITE-*/underlay.yml` | « les sites » | 2 |
|
||||
|
||||
Les indices ne concordent pas toujours, et chaque désaccord a déjà produit un défaut silencieux :
|
||||
|
||||
- **2026-08-14** : `frontiere-plan` voulait poser sur la frontière de Technolibre les règles de
|
||||
Chezlepro. « La fédération » valait « les locataires de ce site », jusqu'au second site.
|
||||
- **2026-09-16** : la console du runner du site affichait zéro machine, sans erreur. Elle
|
||||
cherchait un inventaire de locataire là où il n'y en a pas.
|
||||
- **2026-10-04** : `make ci`, sur le poste, mélangeait le modèle public et les écosystèmes
|
||||
réels. P74 lisait `SETOPS_UNDERLAY` (le modèle) ; P82 lisait le lien `underlay.yml` et les
|
||||
dossiers frères (le site réel).
|
||||
|
||||
Les rôles Ansible, eux, ne posent pas ce problème : ils sont **déjà** le tronc commun. Un même
|
||||
`serveur_postgresql` sert au site et chez un locataire.
|
||||
|
||||
## 2. Le modèle
|
||||
|
||||
```
|
||||
Ecosysteme (tronc commun)
|
||||
/ \
|
||||
Site Locataire
|
||||
\ /
|
||||
`-- contrat --' (associations : un site A des locataires,
|
||||
un locataire A un site)
|
||||
```
|
||||
|
||||
### 2.1 Le tronc commun : `Ecosysteme`
|
||||
|
||||
Ce que tout écosystème possède, quel que soit son contexte :
|
||||
|
||||
- un **nom** et un **dépôt** (`SITE-Chezlepro`, `OPS-Technolibre`) ;
|
||||
- une **voûte** et sa clé (`~/.config/setops-vault-<dépôt>`) ;
|
||||
- un **plan** (`<dépôt>/plan/`) et un **index**, dont dérive son adressage (le site
|
||||
aussi depuis le 2026-09-20) ;
|
||||
- des **machines**, déployées par les **mêmes rôles** : socle, durcissement, PKI, journaux,
|
||||
métriques, supervision, sauvegarde de son propre état ;
|
||||
- une **filiation** : le moteur et le commit dont il descend ;
|
||||
- les **preuves communes** : lint, rendu des gabarits, adressage dérivé, etc.
|
||||
|
||||
Méthodes abstraites, que chaque classe **surcharge** : `inventaire()`, `machines()`,
|
||||
`preuves()`, `verbes()`, `console()`.
|
||||
|
||||
### 2.2 `Site(Ecosysteme)`
|
||||
|
||||
- **Déclaration** : `underlay.yml` (le matériel, les réseaux `site` et `fabric`) et `plan/`
|
||||
(`10-intrants.yml`, serveurs, applications, domaines, bases).
|
||||
- **Inventaire** : dynamique (`site_inventaire.py`). Le site ne dérive rien d'un plan de services ;
|
||||
sa déclaration est sa forme finale.
|
||||
- **Ce qu'il porte en propre** : le matériel (hyperviseurs, commutateurs, frontière),
|
||||
la matérialisation des VM (Proxmox), le SDN, le pare-feu Proxmox, la frontière OPNsense,
|
||||
le DNS public, le dépôt des sauvegardes des locataires, le cache et les artefacts, la forge
|
||||
du génome.
|
||||
- **Relation** : `locataires()`, la liste de `underlay.tenants` résolue en objets
|
||||
`Locataire`. Le site n'en lit que la **face réseau** (§2.4).
|
||||
|
||||
### 2.3 `Locataire(Ecosysteme)`
|
||||
|
||||
- **Déclaration** : `plan/` (nomenclature, serveurs, applications, bases, domaines).
|
||||
- **Inventaire** : généré (`instancier.py` → `hosts.yml`). C'est la **méta-classe** de
|
||||
[`meta-classe.md`](meta-classe.md) : une définition qui engendre toute la flotte.
|
||||
- **Ce qu'il porte en propre** : la configuration de ses services, la remise au client.
|
||||
- **Relation** : `site()`, l'hébergeur que nomme `parente.yml`, résolu en objet `Site`. Le
|
||||
locataire n'en lit que les **intrants exposés** (§2.4).
|
||||
|
||||
### 2.4 Ce que le site et le locataire s'apprennent l'un à l'autre
|
||||
|
||||
Les deux entités **s'informent mutuellement**. Relevé du 2026-10-04 : qui décide de chaque
|
||||
information, où elle vit, et comment elle parvient à l'autre.
|
||||
|
||||
**Ce que chacun a sous la main.** Le runner du site porte le moteur, son dépôt **et ceux de
|
||||
ses locataires** (sans leurs voûtes). Le runner d'un locataire ne porte que le moteur et
|
||||
**son propre** dépôt. Le poste porte tout.
|
||||
|
||||
#### Le site informe le locataire, par trois canaux
|
||||
|
||||
**Canal 1 : des copies écrites à la main** dans le dépôt du locataire.
|
||||
|
||||
| Information | Décidée par | Tenue chez le site dans | Copiée chez le locataire dans | Contrôle |
|
||||
|---|---|---|---|---|
|
||||
| son **index** | le site | `underlay.yml` → `tenants` | `plan/nomenclature.yml` (`index`) | `underlay valider` |
|
||||
| son **adresse publique** | le site | `opnsense.yml` → `opnsense_ips_publiques` | `10-intrants.yml` (`ip_publique`) | — |
|
||||
| les **10 intrants de service** : résolveur, cache, binaires, forge du génome, cible de sauvegarde, DNS public, plan d'administration, passerelle | le site (dérivés de son plan, par `site_intrants.py`) | son plan | `10-intrants.yml`, et `serveur_ops.yml` pour la forge | `site_intrants.py --verifier`, seulement là où les deux dépôts sont présents (le poste) |
|
||||
| sa **racine de confiance** | le site | `ac-racine-site.crt` | le même fichier, copié | — |
|
||||
| *hors contrat* : un dépôt de la forge du site désigné par son adresse | — | — | `serveur_web_dorsal.yml` (Chezlepro) | **aucun** |
|
||||
|
||||
**Canal 2 : une lecture directe, au moment de générer l'inventaire.** `instancier.py` ouvre
|
||||
l'`underlay.yml` et le plan du site pour écrire le `hosts.yml` du locataire. Mesuré sur
|
||||
Technolibre, inventaire généré avec puis sans le site monté : **quatre variables changent**.
|
||||
|
||||
| Variable du locataire | Avec le site monté | Sans le site |
|
||||
|---|---|---|
|
||||
| `chrony_serveurs` | `10.0.4.1` (la frontière) | absente |
|
||||
| `proxmox_pont` | `t23appl` (le VNet SDN) | absente |
|
||||
| `proxmox_etiquette_vlan` | aucune (le SDN étiquette) | `1236` |
|
||||
| `serveur_resolveur_zones_deleguees` | `genese.internal` → `10.37.34.11` | absente |
|
||||
|
||||
**Canal 3 : le réseau.** Le runner du locataire **tire** son génome de la forge du site ; il est
|
||||
né de l'**insémination** par le runner du site.
|
||||
|
||||
#### Le locataire informe le site : le site lit et recalcule
|
||||
|
||||
| Information | Décidée par | Tenue chez le locataire dans | Parvient au site par |
|
||||
|---|---|---|---|
|
||||
| ses **zones** et son adressage | dérivés de l'index | `plan/nomenclature.yml` | le site **lit le fichier** |
|
||||
| les **VM à matérialiser** | le locataire | `plan/serveurs.yml` → `hosts.yml` | le site **lit les fichiers** (placement, clonage, pools, SDN) |
|
||||
| ses **flux** | ses rôles et son plan | `meta/flux.yml` des rôles (moteur), croisés avec son `hosts.yml` | le site **recalcule** lui-même, avec **sa** version du moteur et la totalité de l'inventaire du locataire → frontière, NAT, pare-feu Proxmox |
|
||||
| ses **domaines publics** | le locataire | `plan/domaines.yml` (+ `applications.yml`, `serveurs.yml`) | le site **lit les fichiers** → DNS public secondaire |
|
||||
| sa **clé de sauvegarde** | le locataire | `inventories/*/group_vars/serveur_backup.yml` | le site **lit le fichier** → compte Unix sur le dépôt |
|
||||
| ses **accès d'administration** | le locataire | `plan/acces.yml`, `nftables_admin_ssh` | le site **lit les fichiers** → pairs WireGuard, règles d'administration |
|
||||
|
||||
Le SDN, lui, ne prend aucun flux : il ne filtre pas. Il ne reçoit que l'index, dont il dérive
|
||||
la zone, les 6 VNets et les 6 sous-réseaux.
|
||||
|
||||
#### À l'exécution, entre machines
|
||||
|
||||
Ces échanges-là passent par le réseau, pas par les dépôts. Ils sont déjà déclarés en flux :
|
||||
le locataire **dépose** ses sauvegardes chez le site (SFTP), **tire** ses paquets, ses binaires
|
||||
et son génome, **entre** par le tunnel d'administration du site ; le site **réplique** les
|
||||
zones publiques du locataire (AXFR signé TSIG).
|
||||
|
||||
#### Ce que le relevé montre
|
||||
|
||||
1. **Le site fouille l'intérieur du locataire.** Six fichiers de son plan et de son inventaire,
|
||||
`group_vars` compris. Rien ne dit ce que le locataire **accepte** de montrer. Renommer un
|
||||
champ chez le locataire casse le site sans bruit.
|
||||
2. **Les flux sont calculés deux fois**, par le locataire pour ses `nftables` et par le site pour
|
||||
la frontière et Proxmox, chacun avec **sa** version du moteur. Ils concordent tant que les deux
|
||||
runners tiennent le même commit (c'était le cas le 2026-10-04), mais rien ne l'impose.
|
||||
3. **Le locataire vit de copies** : douze valeurs et un certificat, recopiés à la main, plus une
|
||||
valeur hors contrat. La garde qui compare ne tourne que sur le poste ; le runner du locataire
|
||||
ne peut pas savoir que sa copie a vieilli.
|
||||
4. **L'inventaire d'un locataire dépend du site monté au moment de le générer.** Généré sur le
|
||||
runner du locataire, qui n'a pas le dépôt du site, il perdrait son serveur de temps, son SDN
|
||||
et sa délégation DNS. Ça ne s'est jamais vu, parce que l'inventaire est toujours généré sur le
|
||||
poste puis versionné.
|
||||
5. **Deux décisions du site** (l'index, l'adresse publique) vivent en double.
|
||||
|
||||
#### Proposition : deux fiches, une dans chaque sens
|
||||
|
||||
Chacun **publie** ce qu'il donne à l'autre, dans une fiche **générée** par le moteur, jamais
|
||||
écrite à la main. Chacun ne lit que la fiche que l'autre lui destine. Plus aucune lecture
|
||||
croisée, plus aucun recalcul.
|
||||
|
||||
- **La fiche du site pour un locataire.** Tout ce que le site lui **attribue** (index, adresse
|
||||
publique) et lui **offre** : les 10 intrants, sa racine de confiance, et ce que l'instancier
|
||||
allait lire en douce (serveur de temps, délégation DNS, mode SDN et nom des VNets). Une fiche
|
||||
**par locataire** : aucun ne voit le plan du site ni ses voisins. L'instancier ne lit plus que
|
||||
cette fiche, et l'inventaire devient **identique où qu'on le génère**.
|
||||
- **La face réseau du locataire.** Ce qu'il **demande** au site : VM à matérialiser, zones,
|
||||
domaines publics, clé de sauvegarde publique, accès d'administration, et **ses flux déjà
|
||||
résolus** (adresses, ports, protocoles), ceux avec l'extérieur pour la frontière et ceux de
|
||||
chaque VM pour Proxmox. Le locataire génère déjà ses flux résolus (`flux-genere/*.nft`,
|
||||
`*.connectivite.json`) : la face réseau en est la partie destinée au site. Le site ne
|
||||
recalcule plus rien : il applique ce que le locataire publie, après l'avoir confronté à sa
|
||||
propre politique.
|
||||
- **Chaque fiche porte l'empreinte de sa source**, et une preuve de chaque côté vérifie que la
|
||||
fiche reçue correspond à ce que l'autre a publié.
|
||||
|
||||
**Où en est l'étape 2 (2026-10-04).** La fiche du site (P84), les faits de la face réseau
|
||||
(P85) et les flux de chaque machine (P86) existent, et disent exactement ce que les lectures
|
||||
croisées produisent. La première mesure des flux a trouvé une information que le locataire
|
||||
jetait : les clients nommés d'un port aussi public, que Proxmox doit admettre nommément. Il
|
||||
les publie désormais (`sources_declarees`). La frontière, en trois temps : les identités
|
||||
(P87), les entrées publiques (P88), l'administration (P89) et les sorties (P90) sont faites.
|
||||
**L'étape 2 est terminée** (2026-10-05). Étape 3 : l'instancier lit la fiche déposée par le site, et
|
||||
l'inventaire d'un locataire se génère sans son site, à l'octet près (P91). Le locataire publie sa face
|
||||
réseau (P92) ; les comptes de sauvegarde, le DNS public, le pare-feu Proxmox, la frontière et la
|
||||
découverte des locataires du site la lisent. La matérialisation aussi : la face publie les
|
||||
paramètres de clonage (P93) ; `locataire-creer`, `locataire-raser` et `placement-plan TENANT=`
|
||||
nomment leur locataire au lieu de le monter, et visent les mêmes machines, à l'argument près de
|
||||
la ligne `ansible-playbook` (P94, `test_appels_locataire.py`) ; `reconstruire-locataire` les
|
||||
emploie. Reste : la preuve par reconstruction.
|
||||
|
||||
Méthodes du contrat : `site.fiche_pour(locataire)`, `locataire.face_reseau()`. Une classe
|
||||
n'ouvre jamais les fichiers de l'autre ; une preuve vérifiera la règle.
|
||||
|
||||
#### Ce que les fiches donnent : la portabilité
|
||||
|
||||
Un locataire qui change de site, pour un déménagement, un plan de reprise ou une émancipation,
|
||||
n'a plus qu'à **recevoir la fiche de son nouveau site**. Son dépôt ne contient plus rien
|
||||
d'interne à l'ancien : ni copie d'adresse, ni inventaire généré avec l'ancien site monté. En
|
||||
face, le nouveau site n'a qu'à lire sa **face réseau**. Aujourd'hui, la même bascule demande de
|
||||
corriger des copies dans plusieurs fichiers, puis de régénérer l'inventaire avec le nouveau site
|
||||
monté sur le poste.
|
||||
|
||||
## 3. Le poste : un sélecteur
|
||||
|
||||
Aujourd'hui, le poste monte les deux contextes **en même temps** (`instance/` + `underlay.yml`),
|
||||
et `ConsolePoste` hérite de `ConsoleLocataire`. Désormais :
|
||||
|
||||
- **Le poste choisit un contexte actif** : un site **ou** un locataire. La console ouvre celui-là,
|
||||
et seulement celui-là.
|
||||
- **Une opération qui traverse les deux** nomme ses objets au lieu de les deviner.
|
||||
`reconstruire-locataire` en est l'exemple : le site matérialise, puis le locataire monte.
|
||||
L'orchestration devient `site.materialiser(locataire)` puis `locataire.monter()`.
|
||||
- **Les runners ne changent pas** : celui du site n'a qu'un `Site`, celui d'un locataire qu'un
|
||||
`Locataire`. Leur contexte est désormais **dit**, plus déduit.
|
||||
|
||||
## 4. Qui surcharge quoi
|
||||
|
||||
| Méthode | Tronc commun | Site | Locataire |
|
||||
|---|---|---|---|
|
||||
| `inventaire()` | abstraite | script dynamique (`underlay.yml`) | `hosts.yml` généré du plan |
|
||||
| `machines()` | abstraite | VM du site + équipements | VM du plan |
|
||||
| `adressage()` | dérivé de l'index | zones `site` dérivées, liens `fabric` écrits | 6 zones dérivées |
|
||||
| `sauvegardes()` | son propre état, vérifié par restauration | + héberge les dépôts des locataires | dépose chez son site |
|
||||
| `supervision()` | sondes déclarées par les rôles | + matériel, fabric, frontière | — |
|
||||
| `raser()` / `reconstruire()` | — | `site_raser.py` | `raser.py`, `reconstruire_locataire.py` |
|
||||
| `preuves()` | preuves communes | + preuves de site (P23, P74, P82…) | + preuves de locataire |
|
||||
| `verbes()` | `verifier`, `publier`… | `site-*`, `frontiere-*`, `proxmox-*` | `appliquer`, `instancier`, `remise-*` |
|
||||
| `console()` | — | console SITE | console LOCATAIRE |
|
||||
|
||||
## 5. Ce qui ne change pas
|
||||
|
||||
- Les **rôles Ansible**, déjà communs.
|
||||
- Les **formats de plan** : pas dans ce chantier.
|
||||
- La **doctrine** d'`AGENTS.md`.
|
||||
|
||||
## 6. Le chemin, chaque pas prouvé avant le suivant
|
||||
|
||||
1. **`scripts/contexte.py`** : `Ecosysteme`, `Site`, `Locataire`, `contexte_actif()`,
|
||||
`Site.charger(nom)`, `Locataire.charger(nom)`. Tests unitaires. Rien ne l'utilise encore.
|
||||
2. **Les deux fiches**, générées à côté de l'existant sans rien remplacer :
|
||||
`site.fiche_pour(locataire)` et `locataire.face_reseau()`. Une preuve vérifie que chaque
|
||||
fiche dit **exactement** ce que les lectures croisées d'aujourd'hui produisent.
|
||||
3. **Les consommateurs basculent sur les fiches**, un par un : l'instancier sur la fiche du site
|
||||
(l'inventaire généré doit rester identique, octet pour octet) ; la frontière, Proxmox, le DNS
|
||||
public et les comptes de sauvegarde sur la face réseau (chaque devis doit rester inchangé).
|
||||
4. **`prouver.py`** : chaque preuve déclare son contexte (commun, site, locataire) et reçoit son
|
||||
écosystème du module.
|
||||
5. **Les autres scripts**, un par un, vérifiés par `make verifier` et par un devis inchangé.
|
||||
6. **Une preuve « aucune devinette, aucune lecture croisée »** : les indices du §1, et toute
|
||||
ouverture d'un fichier de l'autre contexte, interdits hors de `contexte.py`.
|
||||
7. **Les verbes du Makefile** rangés par contexte.
|
||||
8. **Les consoles** : le sélecteur et deux consoles (chantier suivant).
|
||||
9. **OPS-Modele** : un locataire modèle, et sans doute un site modèle, vérifiés chacun dans son
|
||||
contexte.
|
||||
|
||||
Après les étapes 3 et 5, une reconstruction prouve que la flotte n'a pas bougé.
|
||||
|
||||
## 7. Décisions et questions ouvertes
|
||||
|
||||
### Tranché par l'exploitant
|
||||
|
||||
- **Deux classes, `Site` et `Locataire`, qui héritent d'un tronc commun** (2026-10-04).
|
||||
- **Le poste est un sélecteur** : un contexte actif à la fois (2026-10-04).
|
||||
- **On commence par le moteur**, les consoles viennent ensuite (2026-10-04).
|
||||
- **Le site dépose sa fiche dans le dépôt du locataire** (2026-10-04), comme il y amorce déjà
|
||||
son runner. Le runner du locataire n'a besoin d'aucun accès au dépôt du site, et ne voit
|
||||
ni le plan du site ni ses voisins.
|
||||
|
||||
- **Le contexte actif se nomme dans un fichier `contexte` explicite** (2026-10-04), une seule
|
||||
valeur : `site:SITE-Chezlepro` ou `locataire:OPS-Technolibre`. Un sélecteur qui monte deux
|
||||
liens à la fois contredirait sa propre règle. Les liens `instance/` et `underlay.yml` restent
|
||||
le temps de la bascule, lus par le seul `contexte.py`.
|
||||
- **Un modèle SITE public, `SITE-Modele`**, à côté d'`OPS-Modele` (2026-10-04). Sans site, la
|
||||
CI ne peut exercer ni les preuves de site (P74, P81, P82) ni la fiche que le site dépose chez
|
||||
le locataire.
|
||||
- **Le site a aussi son `parente.yml`** (2026-10-04) : la filiation est dans le tronc commun.
|
||||
|
|
@ -30,7 +30,7 @@ make placement-plan # chaque VM est-elle là où le plan la met
|
|||
|
||||
## Le trou qu'il comble
|
||||
|
||||
`scripts/prouver.py` porte 83 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le
|
||||
`scripts/prouver.py` porte 94 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le
|
||||
dépôt. Zéro appel réseau, zéro SSH, zéro `ansible`. Elles établissent que le dépôt est
|
||||
cohérent **avec lui-même** — que les handlers existent, que les intrants ont un
|
||||
propriétaire, que rien n'est codé en dur.
|
||||
|
|
|
|||
|
|
@ -86,8 +86,6 @@
|
|||
| `serveur_ops` | ingress | 8090 | tcp | edge, admin | clair | Console d'exploitation servie par l'edge (TLS terminé à l'edge), et joignable depuis le plan d'administration là où il n'y a pas d'edge. Le GUI lui-même reste sur la boucle locale : c'est nginx qui authentifie devant. |
|
||||
| `serveur_ops` | egress | 22 | tcp | flotte | ssh | Piloter la flotte — c'est la raison d'être du poste. |
|
||||
| `serveur_ops` | egress | 443 | tcp | edge | tls-requis | Cloner et resynchroniser le génome depuis la forge de l'écosystème. |
|
||||
| `serveur_ops` | egress | 443 | tcp | voisins_site | tls-requis | Cloner le genome depuis la forge du site, quand cet ecosysteme n'heberge pas la sienne. |
|
||||
| `serveur_ops` | egress | 8006 | tcp | externe | tls-requis | API de l'hyperviseur : créer et cloner les VM d'un écosystème descendant. |
|
||||
| `serveur_ops_site` | egress | 22 | tcp | fabric | ssh | Shell des hyperviseurs : ce que l'API ne couvre pas — configuration reseau, ponts, deplacement de disques. Un pouvoir distinct de l'API, donc declare a part. |
|
||||
| `serveur_ops_site` | egress | 22 | tcp | serveur_ops_tenant | ssh | Insemination : amorcer le runner d'un tenant — socle, moteur, plan, plancher de resolution — pour qu'il prenne ensuite le relais sur ses propres machines. Ne transporte aucun secret : la voute est remise par un humain. |
|
||||
| `serveur_ops_site` | egress | 443 | tcp | fabric | tls-requis | API de la frontiere OPNsense : poser les alias et les regles qui ouvrent les flux du tenant qu'on materialise. Preparer le terrain sans cela laisserait un terrain injoignable. |
|
||||
|
|
@ -136,4 +134,4 @@
|
|||
- **starttls** : 6 flux
|
||||
- **tls** : 10 flux
|
||||
- **tls-cible** : 2 flux
|
||||
- **tls-requis** : 36 flux
|
||||
- **tls-requis** : 34 flux
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ Nommer un point ouvert vaut mieux qu'une ligne rassurante sans mécanisme derri
|
|||
Aucune de ces responsabilités n'est laissée à la parole :
|
||||
|
||||
```
|
||||
make prouver le dépôt est-il cohérent avec lui-même (83 preuves, zéro réseau)
|
||||
make prouver le dépôt est-il cohérent avec lui-même (94 preuves, zéro réseau)
|
||||
make remise-verifier le second temps de la remise est-il fait, ou en retard ? (P80)
|
||||
make certificats-plan ce que le disque porte contre ce que la mémoire sert
|
||||
make expositions-plan chaque service publié répond-il, et depuis où
|
||||
|
|
|
|||
|
|
@ -94,6 +94,10 @@ variables:
|
|||
invite: "Armer sans marquer la pause"
|
||||
valeurs: [oui]
|
||||
facultatif: true
|
||||
SOURCE:
|
||||
invite: "Comparer a quel instantane (vide = celui d'avant rasage)"
|
||||
valeurs: [candidat]
|
||||
facultatif: true
|
||||
VMID:
|
||||
invite: "Identifiant Proxmox de la VM"
|
||||
DIALECTE:
|
||||
|
|
@ -209,6 +213,12 @@ runbooks:
|
|||
- cible: site-intrants
|
||||
nature: mesure
|
||||
pourquoi: "Ce que ce site expose a ses locataires, derive et non declare deux fois."
|
||||
- cible: fiches-site-deposer
|
||||
nature: ecriture
|
||||
pourquoi: >-
|
||||
Deposer chez chaque locataire la fiche que le site lui destine. Son runner n'a pas
|
||||
le depot du site : c'est par elle que son inventaire se genere sans lui. A commiter
|
||||
dans le depot de chaque locataire.
|
||||
- cible: site-verifier
|
||||
nature: mesure
|
||||
pourquoi: "Le playbook du site correspond-il encore aux couches declarees ?"
|
||||
|
|
@ -218,6 +228,21 @@ runbooks:
|
|||
- cible: routes-fabric-etat
|
||||
nature: mesure
|
||||
pourquoi: "Une route de zone manquante est invisible jusqu'au premier invite qui la traverse."
|
||||
- cible: locataire-creer
|
||||
nature: ecriture
|
||||
variables: [TENANT, PARALLELE]
|
||||
fixes: {CONFIRMER: "true"}
|
||||
pourquoi: >-
|
||||
Cloner les VM d'un locataire NOMME, d'apres la face reseau qu'il publie : le
|
||||
runner du site materialise sans monter son depot. Memes machines, memes
|
||||
parametres que `flotte-creer` sur l'instance montee (P93, P94).
|
||||
- cible: locataire-raser
|
||||
nature: destructif
|
||||
variables: [TENANT, INSTANCE]
|
||||
fixes: {CONFIRMER: "true"}
|
||||
pourquoi: >-
|
||||
Detruire les VM d'un locataire NOMME, d'apres sa face : les memes VMID que le
|
||||
plan derive (P94). Son nom court s'ecrit en toutes lettres, comme pour `raser`.
|
||||
- cible: site-raser
|
||||
nature: destructif
|
||||
variables: [SITE]
|
||||
|
|
@ -320,6 +345,12 @@ runbooks:
|
|||
pourquoi: >-
|
||||
Regenerer le registre des flux et les regles nftables depuis les `meta/flux.yml`
|
||||
des roles. Tout ce qui suit en descend.
|
||||
- cible: face-reseau-publier
|
||||
nature: ecriture
|
||||
pourquoi: >-
|
||||
Publier ce que ce locataire demande a son site (`face-reseau.yml`) : ses machines,
|
||||
ses zones, ses flux deja resolus. Le site ne lit plus que ce fichier. Apres `flux`,
|
||||
et a commiter dans le depot du locataire.
|
||||
- cible: devis-proxmox-fw
|
||||
nature: mesure
|
||||
pourquoi: "Le pare-feu est-ouest intra-locataire, derive du registre des flux."
|
||||
|
|
@ -492,7 +523,8 @@ runbooks:
|
|||
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.
|
||||
celui de la nuit, et la journee est perdue. Il est etiquete « avant-raser » et
|
||||
garde jusqu'a la reconstruction suivante : c'est contre lui que jugent les temoins.
|
||||
- cible: raser
|
||||
nature: destructif
|
||||
portee: poste
|
||||
|
|
@ -515,6 +547,14 @@ runbooks:
|
|||
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]
|
||||
|
|
|
|||
|
|
@ -142,16 +142,31 @@ marqueur dans `/etc/setops/restauration/<jeu>` ; ce qui a été remplacé est mi
|
|||
é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 : sinon on perd la journée
|
||||
make restauration-etat [HOTE=...] # ce que chaque jeu est devenu
|
||||
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`, et les répétitions qui ne touchent à
|
||||
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>`.
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@
|
|||
# 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
|
||||
|
|
@ -20,6 +24,7 @@
|
|||
vars:
|
||||
restauration_action: etat
|
||||
restauration_jeu: ""
|
||||
restauration_source: avant-raser
|
||||
tasks:
|
||||
- name: Refuser un renoncement sans jeu nomme
|
||||
ansible.builtin.assert:
|
||||
|
|
@ -49,14 +54,66 @@
|
|||
register: restauration_unite
|
||||
when: restauration_action == 'sauvegarder'
|
||||
|
||||
- name: Deposer maintenant
|
||||
# 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.service
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -160,6 +160,7 @@
|
|||
loop: "{{ genome_a_pousser }}"
|
||||
loop_control:
|
||||
label: "{{ item.dest }}"
|
||||
register: genome_emballe
|
||||
changed_when: true
|
||||
|
||||
- name: Porter les colis jusqu'au runner
|
||||
|
|
@ -255,6 +256,45 @@
|
|||
label: "{{ item.depot }}"
|
||||
index_var: idx
|
||||
|
||||
# LES RELEASES AUSSI (2026-10-07). Le colis ne portait que la branche : `v2026.08.21` et
|
||||
# `v2026.10.07` manquaient a la forge du site. Il porte desormais les etiquettes ; on
|
||||
# demande a la forge si elle a TOUTES celles du poste. 404 = aucune etiquette sur ce
|
||||
# depot, ce qui est juste quand le poste n'en a pas non plus.
|
||||
- name: Demander à la forge les étiquettes qu'elle porte
|
||||
ansible.builtin.uri:
|
||||
url: >-
|
||||
https://forge.{{ domaine_interne }}/api/v1/repos/{{ serveur_ops_forge_organisation
|
||||
| default('genome') }}/{{ item.depot }}/git/refs/tags
|
||||
validate_certs: false
|
||||
status_code: [200, 404]
|
||||
loop: "{{ genome_a_pousser }}"
|
||||
loop_control:
|
||||
label: "{{ item.depot }}"
|
||||
register: genome_forge_etiquettes
|
||||
retries: 4
|
||||
delay: 3
|
||||
until: genome_forge_etiquettes is succeeded
|
||||
changed_when: false
|
||||
|
||||
- name: La forge porte-t-elle toutes les releases du poste ?
|
||||
vars:
|
||||
genome_etiquettes_poste: "{{ (genome_emballe.results[idx].stdout | from_json).etiquettes | default([]) }}"
|
||||
genome_etiquettes_forge: >-
|
||||
{{ (genome_forge_etiquettes.results[idx].json | default([]) if genome_forge_etiquettes.results[idx].status == 200 else [])
|
||||
| map(attribute='ref') | map('regex_replace', '^refs/tags/', '') | list }}
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- genome_etiquettes_poste | difference(genome_etiquettes_forge) | length == 0
|
||||
fail_msg: >-
|
||||
{{ item.depot }} : la forge n'a pas
|
||||
{{ genome_etiquettes_poste | difference(genome_etiquettes_forge) | join(', ') }}.
|
||||
success_msg: >-
|
||||
{{ item.depot }} : {{ genome_etiquettes_poste | length }} etiquette(s) du poste, toutes sur la forge.
|
||||
loop: "{{ genome_a_pousser }}"
|
||||
loop_control:
|
||||
label: "{{ item.depot }}"
|
||||
index_var: idx
|
||||
|
||||
# LE RUNNER DU SITE AVANCE SON PROPRE CLONE, ET SA CONSOLE NE LE SAIT PAS (2026-09-20).
|
||||
#
|
||||
# C'est LUI qui pousse, donc il doit d'abord recevoir : ce playbook fait avancer le
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ Sans eux, le rôle refuse de s'exécuter (assertion).
|
|||
| `client_backup_repo` | `sftp:restic@<cible>:<hôte>` | Un sous-dossier par nœud |
|
||||
| `client_backup_staging` | `/var/backups/setops` | Préparation des dumps |
|
||||
| `client_backup_retention` | `--keep-daily 7 --keep-weekly 4 --keep-monthly 6` | `restic forget` |
|
||||
| `client_backup_etiquette_avant_raser` | `avant-raser` | étiquette du dépôt d'avant rasage, gardée par la rétention jusqu'à la reconstruction suivante |
|
||||
| `client_backup_horaire` | `*-*-* 02:30:00` | Timer systemd |
|
||||
| `client_backup_jobs` | `[]` | **À déclarer par nœud** |
|
||||
|
||||
|
|
@ -58,6 +59,8 @@ Sans eux, le rôle refuse de s'exécuter (assertion).
|
|||
- Restauration : **faite par la reconstruction elle-même** depuis le 2026-09-30 — chaque rôle
|
||||
propriétaire inclut `tasks/restaurer.yml` et remet l'état de l'incarnation précédente ;
|
||||
l'outil de nœud est `setops-restaurer` (cf. `docs/runbooks-exploitation.md` §5.0).
|
||||
- Témoins : `setops-restaurer temoins` (comparateur `setops-temoins`) confronte l'état vivant
|
||||
à l'instantané d'avant rasage ; `make temoins-etat`, et l'étape `bilan` de la reconstruction.
|
||||
|
||||
## Prérequis
|
||||
- `serveur_backup` déployé, et sa `serveur_backup_pubkey` correspondant à la clé privée de
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ client_backup_staging: "/var/backups/setops"
|
|||
|
||||
# Rétention (restic forget).
|
||||
client_backup_retention: "--keep-daily 7 --keep-weekly 4 --keep-monthly 6"
|
||||
# L'instantane d'avant rasage (`make sauvegarder-maintenant`) porte cette etiquette, et la
|
||||
# retention la garde EN PLUS de la regle ci-dessus : jusqu'a la reconstruction suivante
|
||||
# (decision de l'exploitant, 2026-10-07). Voir `templates/sauvegarder.sh.j2`.
|
||||
client_backup_etiquette_avant_raser: "avant-raser"
|
||||
|
||||
# Planification (timer systemd).
|
||||
client_backup_horaire: "*-*-* 02:30:00"
|
||||
|
|
@ -79,6 +83,39 @@ client_backup_catalogue:
|
|||
# PAS DE `serveur_web_frontal` (retire le 2026-09-30) : le frontal RELAIE, il ne sert
|
||||
# rien lui-meme — ses vhosts, son WAF et sa page 404 se redeploient depuis le depot.
|
||||
# `/srv/web` venait de l'epoque ou il servait du statique ; ses instantanes etaient vides.
|
||||
# LES METRIQUES ET LES JOURNAUX (2026-10-07). Ils repartaient de zero a chaque
|
||||
# reconstruction. Ils s'ecrivent en continu : une copie A FROID (service arrete le temps
|
||||
# de copier, `setops-copie-a-froid`) les pose dans le repertoire de preparation, et c'est
|
||||
# cette copie que restic emporte. `vivant` dit ou vit la base, pour la restauration et les
|
||||
# temoins, qui la comparent a l'instantane.
|
||||
serveur_prometheus:
|
||||
nom: prometheus
|
||||
vivant: "{{ serveur_prometheus_donnees | default('/var/lib/prometheus/metrics2') }}"
|
||||
commande: >-
|
||||
/usr/local/sbin/setops-copie-a-froid
|
||||
{{ serveur_prometheus_service | default('prometheus') }}
|
||||
{{ serveur_prometheus_donnees | default('/var/lib/prometheus/metrics2') }}
|
||||
{{ client_backup_staging }}/prometheus/metrics2 --blocs-immuables
|
||||
chemins: ["{{ client_backup_staging }}/prometheus"]
|
||||
serveur_loki:
|
||||
nom: loki
|
||||
vivant: "{{ serveur_loki_chemin | default('/var/lib/loki') }}"
|
||||
commande: >-
|
||||
/usr/local/sbin/setops-copie-a-froid
|
||||
{{ serveur_loki_service | default('loki') }}
|
||||
{{ serveur_loki_chemin | default('/var/lib/loki') }}
|
||||
{{ client_backup_staging }}/loki/donnees --lier chunks --copier chunks/index
|
||||
chemins: ["{{ client_backup_staging }}/loki"]
|
||||
serveur_icinga:
|
||||
# L'AC D'ICINGA FAIT L'IDENTITE DE SA BASE (2026-10-07). Icinga 2 derive l'environnement
|
||||
# d'Icinga DB de la cle publique de son AC, et chaque identifiant d'hote, de service et
|
||||
# d'historique de cet environnement. Recreee a chaque reconstruction, elle rendait
|
||||
# l'historique restaure orphelin : on l'avait donc jete (decision du 2026-09-30, revue
|
||||
# le 2026-10-07 — l'historique, c'est la disponibilite qu'on doit pouvoir prouver).
|
||||
nom: icinga
|
||||
chemins:
|
||||
- "{{ (serveur_icinga_ca | default('/var/lib/icinga2/ca/ca.crt')) | dirname }}"
|
||||
- /var/lib/icinga2/icingadb.env
|
||||
serveur_web_dorsal:
|
||||
nom: web_dorsal
|
||||
chemins: ["{{ serveur_web_dorsal_racine | default('/srv/webapp') }}"]
|
||||
|
|
|
|||
84
roles/client_backup/files/setops-copie-a-froid.sh
Normal file
84
roles/client_backup/files/setops-copie-a-froid.sh
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
#!/bin/bash
|
||||
# Géré par Set-OPS (rôle client_backup). Ne pas éditer à la main.
|
||||
#
|
||||
# setops-copie-a-froid SERVICE SOURCE DESTINATION [--blocs-immuables] [--lier REL]... [--copier REL]...
|
||||
#
|
||||
# Copie COHERENTE d'une base qui ecrit en continu (Prometheus, Loki) : le service est
|
||||
# arrete le temps de la copie, puis redemarre — QUOI QU'IL ARRIVE a la copie, et seulement
|
||||
# s'il tournait avant. La destination n'est remplacee qu'une fois la copie complete.
|
||||
#
|
||||
# POURQUOI A FROID (decision de l'exploitant, 2026-10-07 : « on ne prend pas de risque »).
|
||||
# Copier a chaud un WAL qu'on ecrit, c'est sauvegarder un fichier a moitie ecrit. L'autre
|
||||
# voie — ne garder que les blocs fermes — perdait jusqu'aux deux dernieres heures de
|
||||
# metriques a chaque reconstruction. Mesure au site : 2,9 Go de Prometheus se lisent en
|
||||
# 4,2 s, son WAL seul en 0,3 s ; Loki entier en 2 s.
|
||||
#
|
||||
# `--blocs-immuables` : les sous-dossiers nommes par un ULID (26 caracteres, `01…`) sont
|
||||
# les blocs de Prometheus, que rien ne reecrit apres leur fermeture. On les recopie par
|
||||
# LIENS DURS — instantane, sans place — et le reste (WAL, `chunks_head`) par une vraie
|
||||
# copie. Si la destination est sur un autre systeme de fichiers, on copie tout.
|
||||
#
|
||||
# `--lier REL` lie tout un sous-dossier ; `--copier REL` y copie vraiment un sous-dossier qui,
|
||||
# lui, bouge (2026-10-07). Pour Loki : `--lier chunks --copier chunks/index`. Ses morceaux sont
|
||||
# adresses par leur contenu (`debut:fin:somme`, decode sur site-mon-01) et ne bougent jamais ;
|
||||
# mais `chunks/index/` loge aussi `delete_requests.gz`, la base du compacteur, mesuree
|
||||
# reecrite trois semaines apres sa naissance. La lier, c'etait laisser la sauvegarde changer
|
||||
# avec elle. Copier 6 550 morceaux arretait Loki 13 s ; les lier prend 40 ms.
|
||||
set -euo pipefail
|
||||
|
||||
service="${1:?service}"
|
||||
source="${2:?source}"
|
||||
dest="${3:?destination}"
|
||||
shift 3
|
||||
blocs="" lier=() copier=()
|
||||
while (( $# )); do
|
||||
case "$1" in
|
||||
--blocs-immuables) blocs=1; shift ;;
|
||||
--lier) lier+=("$2"); shift 2 ;;
|
||||
--copier) copier+=("$2"); shift 2 ;;
|
||||
*) echo "copie a froid : option inconnue : $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -d "${source}" ]] || { echo "copie a froid : ${source} n'existe pas — rien a copier." >&2; exit 0; }
|
||||
|
||||
tmp="${dest}.en-cours"
|
||||
rm -rf "${tmp}"
|
||||
mkdir -p "$(dirname "${dest}")"
|
||||
|
||||
actif=0
|
||||
if systemctl is-active --quiet "${service}"; then actif=1; fi
|
||||
relancer() { if (( actif )); then systemctl start "${service}"; fi; }
|
||||
|
||||
debut=$(date +%s%N)
|
||||
if (( actif )); then systemctl stop "${service}"; fi
|
||||
trap relancer EXIT
|
||||
|
||||
lie() { cp -al "$1" "$2" 2>/dev/null || { rm -rf "$2"; cp -a "$1" "$2"; }; }
|
||||
mkdir -p "${tmp}"
|
||||
for e in "${source}"/* "${source}"/.[!.]*; do
|
||||
[[ -e "${e}" ]] || continue
|
||||
n="$(basename "${e}")"
|
||||
if [[ -n "${blocs}" && -d "${e}" && ${#n} -eq 26 && "${n}" == 01* ]] \
|
||||
|| [[ " ${lier[*]:-} " == *" ${n} "* ]]; then
|
||||
lie "${e}" "${tmp}/${n}"
|
||||
else
|
||||
cp -a "${e}" "${tmp}/${n}"
|
||||
fi
|
||||
done
|
||||
# Ce qui bouge, a l'interieur d'un dossier lie : remplace par une vraie copie.
|
||||
for c in "${copier[@]:+${copier[@]}}"; do
|
||||
if [[ -e "${source}/${c}" ]]; then
|
||||
rm -rf "${tmp:?}/${c}"
|
||||
mkdir -p "$(dirname "${tmp}/${c}")"
|
||||
cp -a "${source}/${c}" "${tmp}/${c}"
|
||||
fi
|
||||
done
|
||||
|
||||
trap - EXIT
|
||||
relancer
|
||||
fin=$(date +%s%N)
|
||||
|
||||
rm -rf "${dest}"
|
||||
mv "${tmp}" "${dest}"
|
||||
echo "copie a froid de ${source} : ${service} $( (( actif )) && echo "arrete (jusqu a la relance, rejeu du WAL non compris) $(( (fin - debut) / 1000000 )) ms" || echo "n'etait pas actif")"
|
||||
427
roles/client_backup/files/setops-temoins.py
Normal file
427
roles/client_backup/files/setops-temoins.py
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
#!/usr/bin/env python3
|
||||
# Géré par Set-OPS (rôle client_backup). Ne pas éditer à la main.
|
||||
"""Témoins d'état : l'état VIVANT de ce noeud est-il celui qu'il avait avant le rasage ?
|
||||
|
||||
POURQUOI (2026-10-07). Le bilan d'une reconstruction dit ce que chaque rôle a FAIT
|
||||
(« restauré depuis tel instantané ») : c'est le système qui juge son propre travail. Le
|
||||
2026-09-30, les empreintes avaient été relevées à la main, avant et après ; rien ne le
|
||||
gardait dans le dépôt. Le 2026-10-07 (M4), l'instantané d'avant le rasage a même disparu
|
||||
du dépôt dans l'heure, chassé par la rétention : il ne restait rien à quoi comparer.
|
||||
|
||||
Ce script compare l'instantané extrait par `setops-restaurer temoins` (sous `--avant`) à ce
|
||||
qui vit sur le noeud. Il ne lit que ; il n'écrit rien.
|
||||
|
||||
VERDICTS, PAR JEU :
|
||||
IDENTIQUE rien ne diffère ;
|
||||
CONFORME rien de perdu, l'identité est intacte ; des changements ordinaires, listés
|
||||
(un courriel reçu depuis, une table de sessions, le bayes de rspamd) ;
|
||||
ECART une PERTE (fichier, entrée, base, table), ou un objet d'IDENTITÉ changé :
|
||||
clés et certificats de l'AC, AC d'Icinga et son environnement, clé DKIM,
|
||||
`instanceid` de Nextcloud, mot de passe d'une entrée de l'annuaire ; une
|
||||
base dont plus aucune ligne d'avant ne subsiste ; une ligne d'historique
|
||||
perdue ; des métriques qui ne couvrent plus l'avant-rasage ; des journaux
|
||||
dont aucun morceau d'avant ne subsiste. Exactement ce que la reconstruction du
|
||||
2026-09-30 avait perdu sans que rien ne le dise.
|
||||
|
||||
Code de sortie : 0 sans écart, 1 avec au moins un écart, 2 erreur d'usage.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import collections
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
LISTE_MAX = 8 # détails affichés par catégorie ; le compte, lui, est toujours entier
|
||||
|
||||
# Attributs que l'annuaire tient lui-même : ils changent à chaque écriture, ou à chaque
|
||||
# rejeu (`slapadd` les repose), sans que la donnée ait bougé.
|
||||
OPERATIONNELS = {"entrycsn", "modifytimestamp", "modifiersname", "createtimestamp",
|
||||
"creatorsname", "contextcsn"}
|
||||
|
||||
|
||||
class Rapport:
|
||||
def __init__(self, jeu: str) -> None:
|
||||
self.jeu = jeu
|
||||
self.ecarts: list[str] = [] # pertes et identités changées
|
||||
self.changes: list[str] = [] # changements ordinaires
|
||||
self.retires: list[str] = [] # disparitions ordinaires (un cache, une base compactee)
|
||||
self.nouveaux: list[str] = []
|
||||
self.compte = 0 # objets comparés
|
||||
|
||||
def verdict(self) -> str:
|
||||
if self.ecarts:
|
||||
return "ECART"
|
||||
return "CONFORME" if (self.changes or self.retires or self.nouveaux) else "IDENTIQUE"
|
||||
|
||||
def lignes(self, unite: str) -> list[str]:
|
||||
out = [f" {self.jeu:<12} {self.verdict():<10} {self.compte} {unite}"]
|
||||
for titre, liste in (("ECART", self.ecarts), ("modifie", self.changes),
|
||||
("retire", self.retires), ("nouveau", self.nouveaux)):
|
||||
for x in liste[:LISTE_MAX]:
|
||||
out.append(f" {titre:<8} {x}")
|
||||
if len(liste) > LISTE_MAX:
|
||||
out.append(f" {titre:<8} ... et {len(liste) - LISTE_MAX} autre(s)")
|
||||
return out
|
||||
|
||||
|
||||
def empreinte(p: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with p.open("rb") as f:
|
||||
for bloc in iter(lambda: f.read(1 << 20), b""):
|
||||
h.update(bloc)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def arbre(racine: Path, normaliser=None) -> dict[str, str]:
|
||||
"""{chemin relatif (normalisé) : sha256} des fichiers réguliers sous `racine`."""
|
||||
if racine.is_file():
|
||||
return {racine.name: empreinte(racine)}
|
||||
out: dict[str, str] = {}
|
||||
if not racine.is_dir():
|
||||
return out
|
||||
for p in sorted(racine.rglob("*")):
|
||||
if p.is_file() and not p.is_symlink():
|
||||
rel = p.relative_to(racine).as_posix()
|
||||
out[normaliser(rel) if normaliser else rel] = empreinte(p)
|
||||
return out
|
||||
|
||||
|
||||
def comparer_arbres(r: Rapport, avant: dict[str, str], vivant: dict[str, str], *,
|
||||
strict, perte_stricte) -> None:
|
||||
"""`strict(rel)` : un changement est un écart ; `perte_stricte(rel)` : une perte aussi."""
|
||||
r.compte += len(avant)
|
||||
for rel, h in sorted(avant.items()):
|
||||
if rel not in vivant:
|
||||
if perte_stricte(rel):
|
||||
r.ecarts.append(f"perdu : {rel}")
|
||||
else:
|
||||
r.retires.append(rel)
|
||||
elif vivant[rel] != h:
|
||||
(r.ecarts if strict(rel) else r.changes).append(f"{'IDENTITE ' if strict(rel) else ''}{rel}")
|
||||
r.nouveaux.extend(sorted(set(vivant) - set(avant)))
|
||||
|
||||
|
||||
def jamais(_rel: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def toujours(_rel: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
# --- Les jeux de fichiers ------------------------------------------------------------
|
||||
|
||||
def maildir(rel: str) -> str:
|
||||
"""Un message garde son nom de base : `new/` -> `cur/` à la lecture, `:2,S` aux drapeaux."""
|
||||
rel = re.sub(r"(^|/)(new|cur)/", r"\1msg/", rel)
|
||||
return re.sub(r":2,[A-Za-z]*$", "", rel)
|
||||
|
||||
|
||||
def est_message(rel: str) -> bool:
|
||||
return "/msg/" in f"/{rel}"
|
||||
|
||||
|
||||
def jeu_fichiers(jeu: str, chemins: list[str], avant: Path) -> Rapport:
|
||||
r = Rapport(jeu)
|
||||
for c in chemins:
|
||||
a_avant, a_vivant = avant / c.lstrip("/"), Path(c)
|
||||
if jeu == "step_ca":
|
||||
# Les clés et les certificats SONT l'AC : un octet de différence et plus rien
|
||||
# de ce qu'elle a signé ne se vérifie. Sa base (badger) se compacte d'elle-même.
|
||||
identite = lambda rel: rel.startswith(("certs/", "secrets/")) # noqa: E731
|
||||
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=identite,
|
||||
perte_stricte=identite)
|
||||
elif jeu == "icinga":
|
||||
# L'AC d'Icinga et l'environnement qu'elle derive : changes, chaque identifiant
|
||||
# d'Icinga DB change, et tout l'historique restaure devient orphelin.
|
||||
identite = lambda rel: rel in ("ca.crt", "ca.key", "icingadb.env") # noqa: E731
|
||||
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=identite,
|
||||
perte_stricte=identite)
|
||||
elif jeu == "courriel":
|
||||
comparer_arbres(r, arbre(a_avant, maildir), arbre(a_vivant, maildir),
|
||||
strict=jamais, perte_stricte=est_message)
|
||||
elif jeu == "rspamd":
|
||||
# La clé DKIM est l'identité du domaine ; le bayes, lui, apprend tous les jours.
|
||||
dkim = lambda rel: rel.startswith("dkim/") # noqa: E731
|
||||
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=dkim, perte_stricte=dkim)
|
||||
elif jeu == "nextcloud":
|
||||
if c.rstrip("/").endswith("/config"):
|
||||
instance_id(r, a_avant / "config.php", a_vivant / "config.php")
|
||||
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=jamais,
|
||||
perte_stricte=jamais)
|
||||
else:
|
||||
# Les fichiers des PERSONNES ; `appdata_*` est un cache que Nextcloud refait.
|
||||
fichier = lambda rel: re.match(r"^(?!appdata_)[^/]+/files/", rel) is not None # noqa: E731
|
||||
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=jamais,
|
||||
perte_stricte=fichier)
|
||||
else:
|
||||
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=jamais,
|
||||
perte_stricte=toujours)
|
||||
return r
|
||||
|
||||
|
||||
def instance_id(r: Rapport, avant: Path, vivant: Path) -> None:
|
||||
def lire(p: Path) -> str:
|
||||
m = re.search(r"'instanceid'\s*=>\s*'([^']+)'", p.read_text(errors="replace")) if p.is_file() else None
|
||||
return m.group(1) if m else ""
|
||||
a, v = lire(avant), lire(vivant)
|
||||
if a and a != v:
|
||||
r.ecarts.append(f"IDENTITE instanceid : {a} -> {v or '(absent)'}")
|
||||
|
||||
|
||||
# --- Les bases copiees a froid : Prometheus et Loki (2026-10-07) ----------------------
|
||||
#
|
||||
# Leurs FICHIERS ne se comparent pas : Prometheus fusionne ses blocs, Loki compacte son
|
||||
# index, et la retention (15 j, 31 j) en retire chaque jour. On compare ce qui compte.
|
||||
|
||||
def blocs_prometheus(racine: Path) -> list[tuple[int, int]]:
|
||||
out = []
|
||||
for meta in racine.glob("01*/meta.json") if racine.is_dir() else []:
|
||||
try:
|
||||
m = json.loads(meta.read_text())
|
||||
out.append((int(m["minTime"]), int(m["maxTime"])))
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def segments_wal(racine: Path) -> list[str]:
|
||||
return sorted(p.name for p in (racine / "wal").glob("[0-9]*")) if (racine / "wal").is_dir() else []
|
||||
|
||||
|
||||
def jeu_prometheus(avant: Path, vivant: Path) -> Rapport:
|
||||
"""Les métriques vivantes couvrent-elles encore l'avant-rasage ?
|
||||
|
||||
Le repère : le DÉBUT du bloc le plus récent de l'instantané — quelques heures avant le
|
||||
rasage. Un bloc vivant doit couvrir cet instant. La rétention ne l'a pas retiré (il a
|
||||
moins de 15 jours) ; une base qui n'a pas été restaurée ne peut pas le couvrir : elle
|
||||
est née après la reconstruction. Une base de moins de deux heures n'a pas encore de
|
||||
bloc : son WAL fait alors le repère, et doit se retrouver dans la base vivante, ou y
|
||||
avoir été fermé en bloc.
|
||||
"""
|
||||
r = Rapport("prometheus")
|
||||
a, v = blocs_prometheus(avant), blocs_prometheus(vivant)
|
||||
r.compte = len(a)
|
||||
if a:
|
||||
t = max(debut for debut, _ in a)
|
||||
if not any(debut <= t < fin for debut, fin in v):
|
||||
r.ecarts.append(f"aucun bloc vivant ne couvre {t} (debut du dernier bloc d'avant) : "
|
||||
f"metriques d'avant non restaurees")
|
||||
if v and min(d for d, _ in v) > min(d for d, _ in a):
|
||||
r.changes.append(f"plus ancienne donnee : {min(d for d, _ in a)} -> {min(d for d, _ in v)} (retention)")
|
||||
else:
|
||||
wal = segments_wal(avant)
|
||||
if wal and not v and wal[0] not in segments_wal(vivant):
|
||||
r.ecarts.append(f"le WAL d'avant ({wal[0]}...) n'est pas dans la base vivante, et elle n'a "
|
||||
f"aucun bloc : metriques d'avant non restaurees")
|
||||
if len(v) != len(a):
|
||||
r.changes.append(f"{len(a)} bloc(s) avant, {len(v)} maintenant (fusions, retention, nouveaux)")
|
||||
return r
|
||||
|
||||
|
||||
def jeu_loki(avant: Path, vivant: Path) -> Rapport:
|
||||
"""Des morceaux de journal d'avant subsistent-ils ? Aucun : non restaurés.
|
||||
|
||||
La rétention (31 j) en retire chaque jour : une perte PARTIELLE est listée. Une perte
|
||||
TOTALE, elle, ne vient que d'une base qui n'a pas été remise.
|
||||
"""
|
||||
r = Rapport("loki")
|
||||
a = set(arbre(avant / "chunks"))
|
||||
v = set(arbre(vivant / "chunks"))
|
||||
r.compte = len(a)
|
||||
if a and not (a & v):
|
||||
r.ecarts.append(f"aucun des {len(a)} morceau(x) de journal d'avant ne subsiste : journaux non restaures")
|
||||
elif a - v:
|
||||
r.retires.append(f"{len(a - v)} morceau(x) d'avant retire(s) (retention ?)")
|
||||
if v - a:
|
||||
r.nouveaux.append(f"{len(v - a)} morceau(x) nouveau(x)")
|
||||
return r
|
||||
|
||||
|
||||
# --- L'annuaire ----------------------------------------------------------------------
|
||||
|
||||
def entrees_ldif(texte: str) -> dict[str, dict[str, list[str]]]:
|
||||
"""{dn : {attribut : [valeurs triées]}}, attributs opérationnels exclus."""
|
||||
deplie = re.sub(r"\n ", "", texte.replace("\r\n", "\n"))
|
||||
out: dict[str, dict[str, list[str]]] = {}
|
||||
for bloc in deplie.split("\n\n"):
|
||||
attrs: dict[str, list[str]] = {}
|
||||
dn = ""
|
||||
for ligne in bloc.splitlines():
|
||||
if not ligne or ligne.startswith("#") or ":" not in ligne:
|
||||
continue
|
||||
nom, _, val = ligne.partition(":")
|
||||
if val.startswith(":"):
|
||||
val = base64.b64decode(val[1:].strip()).decode("utf-8", "replace")
|
||||
else:
|
||||
val = val.strip()
|
||||
if nom.lower() == "dn":
|
||||
dn = val.lower()
|
||||
elif nom.lower() not in OPERATIONNELS:
|
||||
attrs.setdefault(nom.lower(), []).append(val)
|
||||
if dn:
|
||||
out[dn] = {k: sorted(v) for k, v in attrs.items()}
|
||||
return out
|
||||
|
||||
|
||||
def jeu_annuaire(avant_ldif: Path, vivant_ldif: Path) -> Rapport:
|
||||
r = Rapport("openldap")
|
||||
if not avant_ldif.is_file():
|
||||
r.ecarts.append(f"l'instantane n'emporte pas d'annuaire ({avant_ldif.name})")
|
||||
return r
|
||||
a = entrees_ldif(avant_ldif.read_text(errors="replace"))
|
||||
v = entrees_ldif(vivant_ldif.read_text(errors="replace")) if vivant_ldif.is_file() else {}
|
||||
r.compte = len(a)
|
||||
for dn, attrs in sorted(a.items()):
|
||||
if dn not in v:
|
||||
r.ecarts.append(f"perdue : {dn}")
|
||||
continue
|
||||
if attrs == v[dn]:
|
||||
continue
|
||||
# Le mot de passe d'une entrée, c'est ce que la reconstruction du 2026-09-30 avait
|
||||
# remis à la valeur d'amorçage sans que rien ne le dise.
|
||||
if attrs.get("userpassword") != v[dn].get("userpassword"):
|
||||
r.ecarts.append(f"IDENTITE userPassword : {dn}")
|
||||
diff = sorted(k for k in set(attrs) | set(v[dn])
|
||||
if k != "userpassword" and attrs.get(k) != v[dn].get(k))
|
||||
if diff:
|
||||
r.changes.append(f"{dn} ({', '.join(diff)})")
|
||||
r.nouveaux.extend(sorted(set(v) - set(a)))
|
||||
return r
|
||||
|
||||
|
||||
# --- PostgreSQL ----------------------------------------------------------------------
|
||||
|
||||
def lire_dump(texte: str) -> tuple[set[str], set[str], dict[tuple[str, str, str], list[str]]]:
|
||||
"""(bases, rôles, {(base, table, 1re colonne) : [valeurs]}) d'après un `pg_dumpall`."""
|
||||
bases = set(re.findall(r"^CREATE DATABASE (\S+) ", texte, re.M))
|
||||
roles = set(re.findall(r"^CREATE ROLE (\S+);", texte, re.M))
|
||||
cles: dict[tuple[str, str, str], list[str]] = {}
|
||||
base, table = "", None
|
||||
for ligne in texte.splitlines():
|
||||
if table is not None:
|
||||
if ligne == "\\.":
|
||||
table = None
|
||||
else:
|
||||
cles[table].append(ligne.split("\t", 1)[0])
|
||||
elif ligne.startswith("\\connect "):
|
||||
base = ligne.split()[-1]
|
||||
else:
|
||||
m = re.match(r"^COPY (\S+) \(([^,)]+)[,)].*FROM stdin;$", ligne)
|
||||
if m:
|
||||
table = (base, m.group(1), m.group(2).strip())
|
||||
cles[table] = []
|
||||
return bases, roles, cles
|
||||
|
||||
|
||||
def psql(base: str, sql: str) -> list[str] | None:
|
||||
r = subprocess.run(["runuser", "-u", "postgres", "--", "psql", "-tAX", "-d", base, "-c", sql],
|
||||
capture_output=True, text=True, env=dict(os.environ, PAGER="cat"))
|
||||
return r.stdout.splitlines() if r.returncode == 0 else None
|
||||
|
||||
|
||||
def jeu_postgresql(dump: Path, interroger=psql) -> Rapport:
|
||||
"""Par CLÉ, pas par nombre de lignes (2026-10-07).
|
||||
|
||||
Compter ne voyait rien : l'historique d'Icinga DB, entièrement remplacé (412 lignes
|
||||
d'avant, toutes perdues ; 380 neuves), passait pour « 412 -> 380 ». On compare donc la
|
||||
première colonne de chaque table, lue au même format que le dump (`COPY ... TO STDOUT`).
|
||||
|
||||
Ce qui est un ÉCART, d'après la mesure sur Chezlepro (avant-raser contre vivant) :
|
||||
- une base dont AUCUNE ligne d'avant ne subsiste, toutes tables confondues : elle n'a
|
||||
pas été restaurée (c'était icingadb, ses 30 tables renouvelées) ;
|
||||
- une ligne perdue dans une table d'HISTORIQUE (`*history*`) : par nature, on y
|
||||
ajoute, on n'y retire pas.
|
||||
Le reste est listé : la découverte de grappe de Keycloak (`jgroups_ping`), ses sessions,
|
||||
une configuration de composant régénérée renouvellent leurs clés dans la vie ordinaire.
|
||||
"""
|
||||
r = Rapport("postgresql")
|
||||
if not dump.is_file():
|
||||
r.ecarts.append(f"l'instantane n'emporte pas de dump ({dump.name})")
|
||||
return r
|
||||
bases, roles, cles = lire_dump(dump.read_text(errors="replace"))
|
||||
vivantes = set(interroger("postgres", "select datname from pg_database") or [])
|
||||
roles_vivants = set(interroger("postgres", "select rolname from pg_roles") or [])
|
||||
for b in sorted(bases - vivantes):
|
||||
r.ecarts.append(f"base perdue : {b}")
|
||||
for x in sorted(roles - roles_vivants):
|
||||
r.ecarts.append(f"role perdu : {x}")
|
||||
par_base: dict[str, list[int]] = collections.defaultdict(lambda: [0, 0])
|
||||
for (b, t, col), avant in sorted(cles.items()):
|
||||
if b not in vivantes or not avant:
|
||||
continue
|
||||
r.compte += 1
|
||||
vivant = interroger(b, f"COPY (select {col} from {t}) TO STDOUT")
|
||||
if vivant is None:
|
||||
r.ecarts.append(f"table perdue : {b}.{t}")
|
||||
continue
|
||||
a, v = set(avant), set(vivant)
|
||||
communes = len(a & v)
|
||||
par_base[b][0] += len(a)
|
||||
par_base[b][1] += communes
|
||||
perdues, nouvelles = len(a) - communes, len(v - a)
|
||||
if perdues and "history" in t:
|
||||
r.ecarts.append(f"historique perdu : {b}.{t} : {perdues} ligne(s) d'avant sur {len(a)}")
|
||||
elif perdues or nouvelles:
|
||||
r.changes.append(f"{b}.{t} : {perdues} retiree(s), {nouvelles} nouvelle(s) (avant {len(a)})")
|
||||
for b, (n, communes) in sorted(par_base.items()):
|
||||
if n and not communes:
|
||||
r.ecarts.insert(0, f"base {b} : aucune des {n} ligne(s) d'avant ne subsiste — non restauree")
|
||||
# `template0` n'est jamais dans un `pg_dumpall` : il n'est pas « nouveau », il est hors jeu.
|
||||
r.nouveaux.extend(f"base {b}" for b in sorted(vivantes - bases - {"postgres", "template0"}))
|
||||
return r
|
||||
|
||||
|
||||
# --- Programme -----------------------------------------------------------------------
|
||||
|
||||
def main(argv: list[str] | None = None, interroger=psql) -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("--avant", required=True, type=Path, help="racine où l'instantané est extrait")
|
||||
ap.add_argument("--instantane", required=True)
|
||||
ap.add_argument("--heure", default="")
|
||||
ap.add_argument("--hote", default="")
|
||||
ap.add_argument("--jeu", action="append", default=[], help="NOM=CHEMIN[:CHEMIN...]")
|
||||
ap.add_argument("--vivant", action="append", default=[],
|
||||
help="NOM=CHEMIN : ou vit la base d'un jeu copie a froid")
|
||||
ap.add_argument("--ldif", default="", help="chemin du LDIF dans l'instantané")
|
||||
ap.add_argument("--ldif-vivant", type=Path, default=None)
|
||||
ap.add_argument("--dump", default="", help="chemin du pg_dumpall dans l'instantané")
|
||||
a = ap.parse_args(argv)
|
||||
|
||||
print(f"TEMOINS de {a.hote} : instantane {a.instantane} ({a.heure}) contre l'etat vivant")
|
||||
if not a.jeu:
|
||||
print(" SANS OBJET : ce noeud ne detient aucun etat.")
|
||||
return 0
|
||||
ecart = False
|
||||
vivants = dict(v.partition("=")[::2] for v in a.vivant)
|
||||
for spec in a.jeu:
|
||||
nom, _, chemins = spec.partition("=")
|
||||
# La copie a froid est le SEUL sous-dossier de la preparation du jeu.
|
||||
copie = lambda: next((d for d in sorted((a.avant / chemins.split(":")[0].lstrip("/")).glob("*")) # noqa: E731
|
||||
if d.is_dir()), a.avant / "absent")
|
||||
if nom == "prometheus" and nom in vivants:
|
||||
r, unite = jeu_prometheus(copie(), Path(vivants[nom])), "bloc(s)"
|
||||
elif nom == "loki" and nom in vivants:
|
||||
r, unite = jeu_loki(copie(), Path(vivants[nom])), "morceau(x)"
|
||||
elif nom == "openldap":
|
||||
r, unite = jeu_annuaire(a.avant / a.ldif.lstrip("/"), a.ldif_vivant or Path("/nonexistent")), "entree(s)"
|
||||
elif nom == "postgresql":
|
||||
r, unite = jeu_postgresql(a.avant / a.dump.lstrip("/"), interroger), "table(s)"
|
||||
else:
|
||||
r, unite = jeu_fichiers(nom, [c for c in chemins.split(":") if c], a.avant), "fichier(s)"
|
||||
print("\n".join(r.lignes(unite)))
|
||||
ecart = ecart or bool(r.ecarts)
|
||||
print("ECART : une perte, ou une identite qui a change." if ecart
|
||||
else "Aucun ecart : rien de perdu, l'identite est intacte.")
|
||||
return 1 if ecart else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -66,3 +66,13 @@
|
|||
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"
|
||||
|
|
|
|||
|
|
@ -56,6 +56,17 @@
|
|||
group: root
|
||||
mode: "0700"
|
||||
|
||||
# LA COPIE A FROID (2026-10-07) : les jeux qui s'ecrivent en continu (Prometheus, Loki)
|
||||
# arretent leur service le temps de copier. L'outil est deploye avec le script qui l'appelle.
|
||||
- name: Déployer l'outil de copie à froid
|
||||
when: client_backup_jobs | length > 0
|
||||
ansible.builtin.copy:
|
||||
src: setops-copie-a-froid.sh
|
||||
dest: /usr/local/sbin/setops-copie-a-froid
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0700"
|
||||
|
||||
- name: Déployer l'unité et le timer systemd
|
||||
when: client_backup_jobs | length > 0
|
||||
ansible.builtin.template:
|
||||
|
|
@ -67,6 +78,7 @@
|
|||
loop:
|
||||
- { s: setops-sauvegarde.service.j2, d: setops-sauvegarde.service }
|
||||
- { s: setops-sauvegarde.timer.j2, d: setops-sauvegarde.timer }
|
||||
- { s: setops-sauvegarde-avant-raser.service.j2, d: setops-sauvegarde-avant-raser.service }
|
||||
|
||||
- name: Activer le timer de sauvegarde
|
||||
when:
|
||||
|
|
@ -115,8 +127,10 @@
|
|||
state: absent
|
||||
loop:
|
||||
- /usr/local/sbin/setops-sauvegarder.sh
|
||||
- /usr/local/sbin/setops-copie-a-froid
|
||||
- /etc/systemd/system/setops-sauvegarde.service
|
||||
- /etc/systemd/system/setops-sauvegarde.timer
|
||||
- /etc/systemd/system/setops-sauvegarde-avant-raser.service
|
||||
- /usr/local/sbin/setops-verifier-mon-depot.sh
|
||||
- /usr/local/sbin/setops-verifier-restauration.sh
|
||||
- /etc/systemd/system/setops-verification-depot.service
|
||||
|
|
|
|||
|
|
@ -28,12 +28,17 @@
|
|||
# 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
|
||||
|
||||
|
|
@ -52,6 +57,12 @@ 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; }
|
||||
|
|
@ -94,6 +105,27 @@ if c:
|
|||
' "$(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() {
|
||||
|
|
@ -127,22 +159,28 @@ vide() { # vrai si le chemin est absent, ou un repertoire sans contenu
|
|||
}
|
||||
|
||||
cmd_fichiers() {
|
||||
local proprio="" remplacer=0 vers="" chemins=() src dest n
|
||||
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
|
||||
extraire "${chemins[@]}"
|
||||
if [[ -n "${depuis}" ]]; then extraire "${depuis}"; else extraire "${chemins[@]}"; fi
|
||||
for c in "${chemins[@]}"; do
|
||||
src="${TMP}${c}"
|
||||
src="${TMP}${depuis:-${c}}"
|
||||
dest="${vers}${c}"
|
||||
if [[ ! -e "${src}" ]]; then echo "ABSENT de ${INSTANTANE} : ${c}"; continue; fi
|
||||
if ! vide "${dest}"; then
|
||||
|
|
@ -274,24 +312,79 @@ cmd_choisir() {
|
|||
"${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
|
||||
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}"
|
||||
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
|
||||
echo "jeu ${j} : $(tr '\n' ' ' < "${MARQUES}/${j}")"
|
||||
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
|
||||
|
|
@ -319,5 +412,6 @@ case "${sous}" in
|
|||
base) cmd_base "$@" ;;
|
||||
acter) cmd_acter "$@" ;;
|
||||
garde) cmd_garde ;;
|
||||
temoins) cmd_temoins "$@" ;;
|
||||
*) mourir "sous-commande inconnue : ${sous}" ;;
|
||||
esac
|
||||
|
|
|
|||
|
|
@ -11,6 +11,18 @@ export RESTIC_PASSWORD_FILE="/etc/setops/restic.pass"
|
|||
# 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
|
||||
|
|
@ -60,7 +72,24 @@ if (( N_PRESENTS == 0 )); then
|
|||
echo "aucun chemin declare n'existe encore sur cet hote — rien a sauvegarder." >&2
|
||||
exit 0
|
||||
fi
|
||||
restic backup --host "{{ inventory_hostname }}" --tag setops "${PRESENTS[@]}"
|
||||
restic backup --host "{{ inventory_hostname }}" "${ETIQUETTES[@]}" "${PRESENTS[@]}"
|
||||
|
||||
# 3 bis. L'etiquette passe au dernier depot d'avant rasage : les precedents rentrent dans
|
||||
# la retention ordinaire. APRES le depot, jamais avant : s'il echoue, l'ancien reste garde.
|
||||
if (( AVANT_RASER )); then
|
||||
ANCIENS=$(restic snapshots --host "{{ inventory_hostname }}" --tag "{{ client_backup_etiquette_avant_raser }}" --json \
|
||||
| python3 -c 'import json, sys
|
||||
import datetime, re
|
||||
def t(x):
|
||||
s = re.sub(r"(\.\d{6})\d+", r"\1", x["time"])
|
||||
return datetime.datetime.fromisoformat(re.sub(r"Z$", "+00:00", s)).timestamp()
|
||||
s = sorted(json.load(sys.stdin) or [], key=t)
|
||||
print(" ".join(x["id"] for x in s[:-1]))')
|
||||
if [[ -n "${ANCIENS}" ]]; then
|
||||
read -r -a ANCIENS_IDS <<< "${ANCIENS}"
|
||||
restic tag --remove "{{ client_backup_etiquette_avant_raser }}" "${ANCIENS_IDS[@]}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4. Rétention.
|
||||
restic forget {{ client_backup_retention }} --prune
|
||||
restic forget {{ client_backup_retention }} --keep-tag "{{ client_backup_etiquette_avant_raser }}" --prune
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
# Géré par Set-OPS (rôle client_backup). Ne pas éditer à la main.
|
||||
# Le depot d'avant rasage : etiquete, garde jusqu'a la reconstruction suivante.
|
||||
# Lance par `make sauvegarder-maintenant` (playbooks/backup/restauration.yml), jamais par un minuteur.
|
||||
[Unit]
|
||||
Description=Sauvegarde Set-OPS d'avant rasage (restic hors-nœud, etiquetee)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/setops-sauvegarder.sh --avant-raser
|
||||
Nice=10
|
||||
IOSchedulingClass=idle
|
||||
|
|
@ -20,3 +20,6 @@ client_backup_groupes_etat:
|
|||
- serveur_nextcloud
|
||||
- serveur_rspamd
|
||||
- serveur_web_dorsal
|
||||
- serveur_icinga
|
||||
- serveur_prometheus
|
||||
- serveur_loki
|
||||
|
|
|
|||
|
|
@ -17,3 +17,12 @@ nftables_baseline_ruleset_genere: "{{ inventory_dir }}/../../flux-genere/{{ inve
|
|||
# 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]
|
||||
|
|
|
|||
|
|
@ -33,10 +33,21 @@
|
|||
# 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, socket, subprocess, sys
|
||||
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"))
|
||||
|
|
@ -78,6 +89,23 @@ 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)
|
||||
|
||||
|
|
@ -132,12 +160,19 @@ 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 hors_regles={len(hors)};;;0"
|
||||
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)}")
|
||||
|
|
|
|||
|
|
@ -155,6 +155,41 @@
|
|||
# (2026-08-13), `hostname -f` rend le nom complet et Icinga s'emet spontanement un
|
||||
# certificat CN et SAN = FQDN. Rien a forcer : il suffisait que la machine sache
|
||||
# comment elle s'appelle.
|
||||
# L'AC D'UNE INCARNATION PRECEDENTE REVIENT AVANT `api setup` (2026-10-07). Icinga 2 en
|
||||
# derive l'environnement d'Icinga DB, donc l'identifiant de chaque hote, service et ligne
|
||||
# d'historique. `api setup` la reutilise si elle est la (« Found CA, skipping and using the
|
||||
# existing one », mesure sur mon-01) ; sinon il en fabrique une, et l'historique restaure
|
||||
# par serveur_postgresql deviendrait orphelin. Aucun paquet n'en cree : c'est donc ICI.
|
||||
- name: L'AC d'Icinga existe-t-elle deja ?
|
||||
ansible.builtin.stat:
|
||||
path: "{{ serveur_icinga_ca }}"
|
||||
register: serveur_icinga_ca_presente
|
||||
|
||||
- name: Restauration — l'AC d'une incarnation precedente ?
|
||||
ansible.builtin.include_role:
|
||||
name: client_backup
|
||||
tasks_from: restaurer.yml
|
||||
vars:
|
||||
client_backup_restaurer_jeu: serveur_icinga
|
||||
client_backup_restaurer_vierge: "{{ not serveur_icinga_ca_presente.stat.exists }}"
|
||||
|
||||
- name: Restauration — remettre l'AC et l'environnement d'Icinga DB
|
||||
changed_when: true
|
||||
ansible.builtin.command:
|
||||
argv: >-
|
||||
{{ ['/usr/local/sbin/setops-restaurer', 'fichiers',
|
||||
'--instantane', client_backup_restauration.instantane,
|
||||
'--proprietaire', 'nagios:nagios'] + client_backup_restauration.chemins }}
|
||||
when: client_backup_restauration.etat == 'a_restaurer'
|
||||
|
||||
- name: Restauration — acter l'AC remise
|
||||
ansible.builtin.include_role:
|
||||
name: client_backup
|
||||
tasks_from: acter.yml
|
||||
vars:
|
||||
client_backup_acter_etat: restaure
|
||||
when: client_backup_restauration.etat == 'a_restaurer'
|
||||
|
||||
- name: Configurer l'API Icinga 2
|
||||
ansible.builtin.command:
|
||||
cmd: icinga2 api setup
|
||||
|
|
@ -174,12 +209,20 @@
|
|||
enabled: true
|
||||
state: started
|
||||
|
||||
- name: Importer le schema Icinga DB dans PostgreSQL (une fois)
|
||||
# SUR UNE BASE VIERGE SEULEMENT (2026-10-07). Le marqueur vit sur mon-01 : une machine
|
||||
# reconstruite ne l'a plus, alors que la base, elle, revient restauree par serveur_postgresql
|
||||
# (historique compris). Rejouer le schema par-dessus y ajouterait une version et des erreurs
|
||||
# « already exists » que psql taisait. La base dit elle-meme si elle a deja son schema.
|
||||
- name: Importer le schema Icinga DB dans PostgreSQL (une fois, base vierge)
|
||||
ansible.builtin.shell:
|
||||
cmd: >-
|
||||
PGPASSWORD='{{ serveur_icinga_db_password }}'
|
||||
export PGPASSWORD='{{ serveur_icinga_db_password }}';
|
||||
deja=$(psql -h {{ serveur_icinga_db_host }} -U {{ serveur_icinga_entree.proprietaire }}
|
||||
-d {{ serveur_icinga_entree.base }} -tAX
|
||||
-c "select to_regclass('public.icingadb_schema') is not null");
|
||||
if [ "$deja" != t ]; then
|
||||
psql -h {{ serveur_icinga_db_host }} -U {{ serveur_icinga_entree.proprietaire }}
|
||||
-d {{ serveur_icinga_entree.base }} -f {{ serveur_icinga_schema }}
|
||||
-d {{ serveur_icinga_entree.base }} -f {{ serveur_icinga_schema }}; fi
|
||||
&& touch /etc/icingadb/.schema-imported
|
||||
creates: /etc/icingadb/.schema-imported
|
||||
no_log: true
|
||||
|
|
|
|||
|
|
@ -195,6 +195,56 @@
|
|||
group: loki
|
||||
mode: "0750"
|
||||
|
||||
# --- LA BASE D'UNE INCARNATION PRECEDENTE (2026-10-07) --------------------------------
|
||||
#
|
||||
# Les journaux repartaient de zero a chaque reconstruction. client_backup en garde une
|
||||
# copie a froid (jeu `loki`) ; on la remet ICI, avant que Loki ne demarre sur sa
|
||||
# configuration : une base sans aucun morceau de journal est une base neuve. Service
|
||||
# arrete pendant la remise ; « Activer et demarrer Loki » plus bas le relance.
|
||||
- name: La base de Loki a-t-elle deja des morceaux de journal ?
|
||||
ansible.builtin.find:
|
||||
paths: "{{ serveur_loki_chemin }}/chunks"
|
||||
recurse: true
|
||||
register: serveur_loki_morceaux
|
||||
|
||||
- name: Restauration — la base d'une incarnation precedente ?
|
||||
ansible.builtin.include_role:
|
||||
name: client_backup
|
||||
tasks_from: restaurer.yml
|
||||
vars:
|
||||
client_backup_restaurer_jeu: serveur_loki
|
||||
client_backup_restaurer_vierge: "{{ serveur_loki_morceaux.matched == 0 }}"
|
||||
|
||||
- name: Restauration — remettre la base, service arrete
|
||||
when: client_backup_restauration.etat == 'a_restaurer'
|
||||
block:
|
||||
- name: Restauration — arreter Loki
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ serveur_loki_service }}"
|
||||
state: stopped
|
||||
|
||||
- name: Restauration — remettre la copie a froid a sa place
|
||||
changed_when: true
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/local/sbin/setops-restaurer
|
||||
- fichiers
|
||||
- --instantane
|
||||
- "{{ client_backup_restauration.instantane }}"
|
||||
- --depuis
|
||||
- "{{ client_backup_restauration.chemins[0] }}/donnees"
|
||||
- --remplacer
|
||||
- --proprietaire
|
||||
- loki:loki
|
||||
- "{{ serveur_loki_chemin }}"
|
||||
|
||||
- name: Restauration — acter la base remise
|
||||
ansible.builtin.include_role:
|
||||
name: client_backup
|
||||
tasks_from: acter.yml
|
||||
vars:
|
||||
client_backup_acter_etat: restaure
|
||||
|
||||
# --- TLS : synchroniser le cert step_ca AVANT d'activer http_tls_config ---
|
||||
- name: TLS — repertoire des certificats (loki)
|
||||
ansible.builtin.file:
|
||||
|
|
|
|||
|
|
@ -41,24 +41,26 @@ flux:
|
|||
pair: flotte
|
||||
chiffrement: ssh
|
||||
raison: "Piloter la flotte — c'est la raison d'être du poste."
|
||||
# L'HYPERVISEUR N'EST PAS DANS L'ÉCOSYSTÈME : il appartient au monde physique, de
|
||||
# l'autre côté de la frontière. C'est donc un pair `externe`, et ce flux est le seul
|
||||
# par lequel un écosystème peut en engendrer un autre.
|
||||
- sens: egress
|
||||
port: 8006
|
||||
protocole: tcp
|
||||
pair: externe
|
||||
chiffrement: tls-requis
|
||||
raison: "API de l'hyperviseur : créer et cloner les VM d'un écosystème descendant."
|
||||
# LIRE LE GENOME CHEZ UN VOISIN. La forge du genome se mutualise par SITE : le moteur,
|
||||
# les modeles et la fabric sont le meme contenu pour tous les tenants. Seul le plan
|
||||
# `OPS-xxx` differe.
|
||||
# UN LOCATAIRE NE PARLE PAS A L'HYPERVISEUR — ET CE N'EST PLUS DECLARE ICI (2026-10-05).
|
||||
#
|
||||
# La forge DE TRAVAIL d'un ecosysteme -- celle qui sert ses gens -- reste un service du
|
||||
# tenant, et n'a rien a voir avec ce flux.
|
||||
- sens: egress
|
||||
port: 443
|
||||
protocole: tcp
|
||||
pair: voisins_site
|
||||
chiffrement: tls-requis
|
||||
raison: "Cloner le genome depuis la forge du site, quand cet ecosysteme n'heberge pas la sienne."
|
||||
# Ce fichier portait une sortie 8006 vers `externe` : « API de l'hyperviseur : créer et
|
||||
# cloner les VM d'un écosystème descendant ». Mesure du 2026-10-05, depuis trois machines de
|
||||
# deux locataires : AUCUN paquet ne passait. La frontiere rendait cette sortie vers « tout
|
||||
# sauf les plages privees », et les hyperviseurs sont en plages privees ; elle refusait donc
|
||||
# (journal : `block`, refus audible du lien de transit). La regle etait morte, et sa lecture
|
||||
# mentait : elle disait qu'un locataire pouvait creer des VM.
|
||||
#
|
||||
# Materialiser est un pouvoir du SITE (`serveur_ops_site`, sa voute porte l'API Proxmox).
|
||||
# Une emancipation qui en aurait besoin se declarera le jour ou elle existera.
|
||||
# LE GENOME SE CLONE A LA FORGE DU SITE — ET CE N'EST PLUS DECLARE ICI (2026-10-05).
|
||||
#
|
||||
# Ce fichier portait une sortie 443 vers `voisins_site`, ecrite quand la forge du genome
|
||||
# vivait chez un locataire voisin (2026-08-24). Elle est au SITE depuis ; le pair n'avait
|
||||
# pas suivi. `voisins_site` vu d'un locataire, ce sont les AUTRES locataires : la frontiere
|
||||
# ouvrait donc le 443 du runner de chaque locataire vers tout le supernet de l'autre — un
|
||||
# passage entre locataires que rien ne justifiait, la ou elle doit refuser par defaut.
|
||||
# Trouve en confrontant la frontiere a la face reseau (docs/conception-contextes.md).
|
||||
#
|
||||
# Le clonage passe par la regle que declare `serveur_forge_site` (son entree depuis
|
||||
# `voisins_site`, vue du site : ses locataires). La forge DE TRAVAIL d'un ecosysteme,
|
||||
# celle qui sert ses gens, reste un service du tenant, sans rapport avec ce flux.
|
||||
|
|
|
|||
|
|
@ -80,11 +80,13 @@ serveur_postgresql_exportateur_mot_de_passe: "{{ vault_pg_exportateur | default(
|
|||
# configuration vont ensemble, et le code de ses applications (user_oidc,
|
||||
# richdocuments) n'est pas sauvegarde : restauree avant son installation, la base
|
||||
# les dirait installees sans qu'elles le soient, et `occ app:install` refuserait ;
|
||||
# - icinga : l'historique de supervision, lie a l'environnement d'Icinga, que rien ne
|
||||
# sauvegarde. Il repart de zero, comme le reste de la supervision.
|
||||
#
|
||||
# PLUS `icinga` (2026-10-07). On l'excluait : l'historique de supervision etait lie a
|
||||
# l'environnement d'Icinga, que l'AC d'Icinga, recreee a chaque reconstruction, renouvelait
|
||||
# — restauree, la base serait restee orpheline. L'AC est desormais sauvegardee et remise
|
||||
# (jeu `icinga` de client_backup) : l'environnement survit, et l'historique avec lui.
|
||||
serveur_postgresql_restauration_consommateurs_exclus:
|
||||
- nextcloud
|
||||
- icinga
|
||||
|
||||
serveur_postgresql_bases_restaurables: >-
|
||||
{{ ((serveur_postgresql_registre | default([]))
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ serveur_prometheus_paquets:
|
|||
|
||||
serveur_prometheus_service: "prometheus"
|
||||
serveur_prometheus_config: "/etc/prometheus/prometheus.yml"
|
||||
# Le dossier de la base (defaut du paquet Debian, que `ARGS` ne change pas). Lu aussi par
|
||||
# client_backup : la copie a froid et la restauration le visent.
|
||||
serveur_prometheus_donnees: "/var/lib/prometheus/metrics2"
|
||||
|
||||
serveur_prometheus_intervalle: "15s"
|
||||
serveur_prometheus_retention: "15d"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,58 @@
|
|||
update_cache: true
|
||||
cache_valid_time: 3600
|
||||
|
||||
# --- LA BASE D'UNE INCARNATION PRECEDENTE (2026-10-07) --------------------------------
|
||||
#
|
||||
# Les metriques repartaient de zero a chaque reconstruction. client_backup en garde une
|
||||
# copie a froid (jeu `prometheus`) ; on la remet ICI, juste apres l'installation : le paquet
|
||||
# demarre Prometheus aussitot, mais sa base n'a encore aucun bloc (le premier se ferme au
|
||||
# bout de deux heures). Service arrete pendant la remise ; la tache « Activer et demarrer »
|
||||
# plus bas le relance.
|
||||
- name: La base de Prometheus a-t-elle deja des blocs ?
|
||||
ansible.builtin.find:
|
||||
paths: "{{ serveur_prometheus_donnees }}"
|
||||
file_type: directory
|
||||
patterns: "01*"
|
||||
register: serveur_prometheus_blocs
|
||||
|
||||
- name: Restauration — la base d'une incarnation precedente ?
|
||||
ansible.builtin.include_role:
|
||||
name: client_backup
|
||||
tasks_from: restaurer.yml
|
||||
vars:
|
||||
client_backup_restaurer_jeu: serveur_prometheus
|
||||
client_backup_restaurer_vierge: "{{ serveur_prometheus_blocs.matched == 0 }}"
|
||||
|
||||
- name: Restauration — remettre la base, service arrete
|
||||
when: client_backup_restauration.etat == 'a_restaurer'
|
||||
block:
|
||||
- name: Restauration — arreter Prometheus
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ serveur_prometheus_service }}"
|
||||
state: stopped
|
||||
|
||||
- name: Restauration — remettre la copie a froid a sa place
|
||||
changed_when: true
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/local/sbin/setops-restaurer
|
||||
- fichiers
|
||||
- --instantane
|
||||
- "{{ client_backup_restauration.instantane }}"
|
||||
- --depuis
|
||||
- "{{ client_backup_restauration.chemins[0] }}/metrics2"
|
||||
- --remplacer
|
||||
- --proprietaire
|
||||
- prometheus:prometheus
|
||||
- "{{ serveur_prometheus_donnees }}"
|
||||
|
||||
- name: Restauration — acter la base remise
|
||||
ansible.builtin.include_role:
|
||||
name: client_backup
|
||||
tasks_from: acter.yml
|
||||
vars:
|
||||
client_backup_acter_etat: restaure
|
||||
|
||||
# --- LES CIBLES QUE LES ROLES DECLARENT (2026-09-14) ----------------------------------
|
||||
#
|
||||
# `serveur_prometheus_cibles_supplementaires` existait depuis longtemps — une liste libre,
|
||||
|
|
|
|||
1463
scripts/contexte.py
Normal file
1463
scripts/contexte.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -297,6 +297,57 @@ def cibles_par_role(role: str, inventaire: Path) -> list[str]:
|
|||
#
|
||||
# L'outil a bien refuse d'appliquer quoi que ce soit, mais le devis, lui, annoncait un
|
||||
# objet impossible. Un devis qui promet ce que la cible refusera n'est pas un devis.
|
||||
# LA FACE RESEAU D'ABORD (2026-10-05, docs/conception-contextes.md §6, etape 3). Le locataire
|
||||
# publie `face-reseau.yml` : ses groupes et leurs adresses, son administration, son tunnel, ses
|
||||
# zones publiques, le verdict de ses flux conditionnels. La frontiere ne lit plus son inventaire,
|
||||
# ses variables ni son plan. Sans face publiee (transition), l'ancienne lecture, et on le dit.
|
||||
_FACES: dict[str, dict | None] = {}
|
||||
|
||||
|
||||
def face_publiee(nom: str) -> dict | None:
|
||||
if nom not in _FACES:
|
||||
try:
|
||||
import contexte
|
||||
from devis_reseau import DOSSIER_INSTANCES
|
||||
_FACES[nom] = contexte.Locataire(DOSSIER_INSTANCES / nom).face_publiee()
|
||||
except Exception:
|
||||
_FACES[nom] = None
|
||||
if _FACES[nom] is None:
|
||||
print(f"note : {nom} n'a pas publie sa face reseau — lecture de ses fichiers "
|
||||
f"internes (transition).", file=sys.stderr)
|
||||
return _FACES[nom]
|
||||
|
||||
|
||||
def cibles_du_tenant(role: str, nom: str) -> list[str]:
|
||||
"""IP des hotes qui portent ce role chez ce locataire : sa face, sinon son inventaire."""
|
||||
face = face_publiee(nom)
|
||||
if face is not None:
|
||||
ips = {h: m.get("ip") for h, m in (face.get("machines") or {}).items()}
|
||||
return sorted({ips[h] for h in (face.get("groupes") or {}).get(role, []) if ips.get(h)})
|
||||
inv = inventaire_de(nom)
|
||||
return cibles_par_role(role, inv) if inv else []
|
||||
|
||||
|
||||
def admin_du_tenant(nom: str) -> list[str]:
|
||||
"""Ses reseaux d'administration declares (l'intrant) : sa face, sinon ses `group_vars`."""
|
||||
face = face_publiee(nom)
|
||||
if face is not None:
|
||||
return list((face.get("administration") or {}).get("intrant") or [])
|
||||
return admin_de(nom)
|
||||
|
||||
|
||||
def applicable_chez(fl: dict, role: str, nom: str) -> bool:
|
||||
"""Le flux s'applique-t-il chez ce locataire ? Son verdict publie, sinon ses variables."""
|
||||
if not fl.get("seulement_si"):
|
||||
return True
|
||||
face = face_publiee(nom)
|
||||
if face is not None:
|
||||
import contexte
|
||||
return bool(((face.get("conditions") or {}).get(role) or {}).get(contexte.cle_condition(fl), True))
|
||||
inv = inventaire_de(nom)
|
||||
return flux_applicable(fl, role, inv.parent.parent.parent) if inv else True
|
||||
|
||||
|
||||
LONGUEUR_MAX_ALIAS = 31
|
||||
|
||||
# Meme abreviation que le pare-feu est-ouest (`devis_proxmox_fw` : serveur_ -> srv-), pour
|
||||
|
|
@ -495,7 +546,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
admin_par_if: dict[str, dict[str, list[str]]] = {}
|
||||
for nom, pfx, n in tenants:
|
||||
etiquette = f"{pfx}{n['index']}"
|
||||
cidrs = admin_de(nom)
|
||||
cidrs = admin_du_tenant(nom)
|
||||
admin_par_tenant[etiquette] = cidrs
|
||||
par_vpn = [c for c in cidrs if _par_le_vpn(c)]
|
||||
locaux = [c for c in cidrs if c not in par_vpn and _porte_par(c, reseaux_gestion)]
|
||||
|
|
@ -559,13 +610,12 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
for nom, pfx, n in tenants:
|
||||
etiquette = f"{pfx}{n['index']}"
|
||||
alias_tenant = f"SETOPS_TENANT_{etiquette}"
|
||||
inv = inventaire_de(nom)
|
||||
if inv is None:
|
||||
if face_publiee(nom) is None and inventaire_de(nom) is None:
|
||||
tenants_sans_inventaire.append(nom)
|
||||
continue
|
||||
for role, fl in retenus:
|
||||
# Un flux conditionnel (`seulement_si`) ne vaut que la ou son reglage le dit.
|
||||
if not flux_applicable(fl, role, inv.parent.parent.parent):
|
||||
if not applicable_chez(fl, role, nom):
|
||||
continue
|
||||
# AVANT L'ALIAS (2026-09-17). Le saut plus bas arrivait APRES la creation de
|
||||
# l'alias du role : un flux vers la frontiere declare par `serveur_prometheus`
|
||||
|
|
@ -576,7 +626,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
if role == ROLE_SOCLE:
|
||||
destination = alias_tenant
|
||||
else:
|
||||
cibles = cibles_par_role(role, inv)
|
||||
cibles = cibles_du_tenant(role, nom)
|
||||
if not cibles:
|
||||
continue # role absent de ce tenant : aucune regle a poser
|
||||
a = nom_alias(role, etiquette)
|
||||
|
|
@ -887,9 +937,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
# au SSH du site rendrait l'insemination indiscernable d'un droit d'entree
|
||||
# general — exactement ce que l'isolation refuse.
|
||||
_runners_tenants = sorted({ip for _n2, _p2, _x2 in tenants
|
||||
for ip in (cibles_par_role("serveur_ops_tenant",
|
||||
inventaire_de(_n2))
|
||||
if inventaire_de(_n2) else [])})
|
||||
for ip in cibles_du_tenant("serveur_ops_tenant", _n2)})
|
||||
if _runners_tenants:
|
||||
alias["SETOPS_RUNNERS_TENANTS"] = {
|
||||
"type": "host",
|
||||
|
|
@ -909,6 +957,9 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
# n'y ecoutait — mais un chemin ouvert vers une machine qui n'en a pas besoin est
|
||||
# exactement ce que le moindre privilege refuse.
|
||||
def _publie(_nom_tenant: str) -> bool:
|
||||
_face = face_publiee(_nom_tenant)
|
||||
if _face is not None:
|
||||
return bool((_face.get("dns_public") or {}).get("zones"))
|
||||
try:
|
||||
import devis_reseau as _dr
|
||||
_f = _dr.DOSSIER_INSTANCES / _nom_tenant / "plan" / "domaines.yml"
|
||||
|
|
@ -918,9 +969,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
except (OSError, ImportError):
|
||||
return False
|
||||
_primaires_dns = sorted({ip for _n2, _p2, _x2 in tenants if _publie(_n2)
|
||||
for ip in (cibles_par_role("serveur_powerdns",
|
||||
inventaire_de(_n2))
|
||||
if inventaire_de(_n2) else [])})
|
||||
for ip in cibles_du_tenant("serveur_powerdns", _n2)})
|
||||
if _primaires_dns:
|
||||
alias["SETOPS_DNS_PRIMAIRES_TENANTS"] = {
|
||||
"type": "host",
|
||||
|
|
@ -1574,15 +1623,21 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
_idx_t = _n_t.get("index")
|
||||
if _idx_t is None:
|
||||
continue
|
||||
try:
|
||||
_acces_t = (yaml.safe_load(
|
||||
(_dr.DOSSIER_INSTANCES / _nom_t / "plan" / "acces.yml").read_text(encoding="utf-8"))
|
||||
or {}).get("acces_admin_vpn") or {}
|
||||
except (OSError, yaml.YAMLError):
|
||||
continue
|
||||
if not [n for n, c in _acces_t.items()
|
||||
if isinstance(c, dict) and str(c.get("etat", "present")) == "present"]:
|
||||
continue # aucun pair actif : aucune route, aucune regle
|
||||
_face_t = face_publiee(_nom_t)
|
||||
if _face_t is not None:
|
||||
# Le tunnel n'existe que s'il a un pair actif : la face le dit (`tunnel` vide sinon).
|
||||
if not (_face_t.get("administration") or {}).get("tunnel"):
|
||||
continue
|
||||
else:
|
||||
try:
|
||||
_acces_t = (yaml.safe_load(
|
||||
(_dr.DOSSIER_INSTANCES / _nom_t / "plan" / "acces.yml").read_text(encoding="utf-8"))
|
||||
or {}).get("acces_admin_vpn") or {}
|
||||
except (OSError, yaml.YAMLError):
|
||||
continue
|
||||
if not [n for n, c in _acces_t.items()
|
||||
if isinstance(c, dict) and str(c.get("etat", "present")) == "present"]:
|
||||
continue # aucun pair actif : aucune route, aucune regle
|
||||
_etiq_t = f"{_pfx_t}{_idx_t}"
|
||||
_alias_tunnel = f"SETOPS_ADMIN_{_etiq_t}_TUNNEL"
|
||||
alias[_alias_tunnel] = {
|
||||
|
|
|
|||
|
|
@ -115,10 +115,13 @@ def _ponts_derives(base: Path | None) -> set[str]:
|
|||
|
||||
|
||||
def constater(api: Cluster, place: dict, base: Path | None = None,
|
||||
routage: str = "sdn") -> list[dict]:
|
||||
"""Confronte chaque valeur au cluster. Renvoie une ligne par objet."""
|
||||
routage: str = "sdn", ponts: set[str] | None = None) -> list[dict]:
|
||||
"""Confronte chaque valeur au cluster. Renvoie une ligne par objet.
|
||||
|
||||
`ponts` : ceux que la face reseau du locataire publie (`--locataire`) ; sans eux, ceux de
|
||||
l'inventaire de l'instance montee."""
|
||||
lignes: list[dict] = []
|
||||
place = {**place, "_instance": base}
|
||||
place = {**place, "_instance": base, "_ponts": ponts}
|
||||
|
||||
noeuds = [n.get("node") for n in _liste(api, "/nodes")]
|
||||
voulu = place.get("proxmox_clone_noeud")
|
||||
|
|
@ -148,7 +151,8 @@ def constater(api: Cluster, place: dict, base: Path | None = None,
|
|||
#
|
||||
# D-80 corrigee le 2026-08-13 dit deja que la liaison de placement est nœud, stockage
|
||||
# et gabarit — le pont se derive. Le devis, lui, continuait de compter quatre objets.
|
||||
ponts_derives = sorted(_ponts_derives(place.get("_instance")))
|
||||
ponts_derives = sorted(place["_ponts"] if place.get("_ponts") is not None
|
||||
else _ponts_derives(place.get("_instance")))
|
||||
if routage == "sdn":
|
||||
vnets = {v.get("vnet") for v in _liste(api, "/cluster/sdn/vnets")}
|
||||
manquants = [p for p in ponts_derives if p not in vnets]
|
||||
|
|
@ -191,10 +195,40 @@ def constater(api: Cluster, place: dict, base: Path | None = None,
|
|||
return lignes
|
||||
|
||||
|
||||
def afficher(lignes: list[dict], source: Path | None, depot: Path) -> bool:
|
||||
# `.resolve()` : sans lui, un tenant monte par symlink s'annonce « instance » — le nom
|
||||
# du lien, pas celui du tenant. Un devis doit nommer ce qu'il a mesure.
|
||||
tenant = source.parents[3].resolve().name if source else "?"
|
||||
def nom_du_tenant(source: Path | None, locataire: str | None = None) -> str:
|
||||
"""Le nom que le devis annonce. Un devis doit nommer ce qu'il a mesure.
|
||||
|
||||
`.resolve()` : sans lui, un tenant monte par symlink s'annonce « instance » — le nom du
|
||||
lien, pas celui du tenant. NOMME (`--locataire`, 2026-10-07) : il n'y a pas de fichier
|
||||
source d'ou tirer le nom, le devis annoncait « ? » ; le nom est celui qu'on a donne.
|
||||
"""
|
||||
if locataire:
|
||||
return locataire
|
||||
return source.parents[3].resolve().name if source else "?"
|
||||
|
||||
|
||||
def ce_que_le_clonage_utilise(place: dict, gabarit: dict) -> dict:
|
||||
"""Le placement tel que `cloner-vm` le passe REELLEMENT a Proxmox.
|
||||
|
||||
LE DEVIS VERIFIAIT CE QUE PERSONNE NE CLONE (2026-10-07). Depuis le 2026-09-01, le gabarit
|
||||
et son stockage viennent du SITE (`underlay.py --gabarit`) : `cloner-vm` les passe en
|
||||
`-e`, par-dessus ce que dit le tenant. Le devis, lui, lisait encore le tenant. Chez
|
||||
Technolibre, il validait le 99998, que plus rien ne clone (M4 est nee du 9006, sur
|
||||
CephNVMe) ; chez Chezlepro, qui ne declare plus de gabarit (c'est la regle), il refusait
|
||||
« None » — trouve au pre-vol de sa reconstruction.
|
||||
|
||||
Meme regle que le Makefile : le site decide ; s'il ne declare rien, le tenant reprend
|
||||
la main. Degrader, jamais deviner.
|
||||
"""
|
||||
vu = dict(place)
|
||||
if gabarit.get("vmid") not in (None, ""):
|
||||
vu["proxmox_clone_vmid_modele"] = int(gabarit["vmid"])
|
||||
if gabarit.get("stockage"):
|
||||
vu["proxmox_clone_stockage"] = gabarit["stockage"]
|
||||
return vu
|
||||
|
||||
|
||||
def afficher(lignes: list[dict], tenant: str, depot: Path) -> bool:
|
||||
print(f"Devis du placement — tenant « {tenant} » sur l'hebergeur « {depot.name} »\n")
|
||||
print(f" {'OBJET':<12} {'DEMANDE':<34} {'VERDICT'}")
|
||||
ok = True
|
||||
|
|
@ -216,11 +250,27 @@ def afficher(lignes: list[dict], source: Path | None, depot: Path) -> bool:
|
|||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--verifier", action="store_true", help="code de sortie seulement")
|
||||
ap.add_argument("--locataire", help="le locataire NOMME, d'apres sa face reseau publiee "
|
||||
"(le site, sans monter son depot)")
|
||||
a = ap.parse_args()
|
||||
|
||||
place, source = placement_du_tenant()
|
||||
ponts = None
|
||||
if a.locataire:
|
||||
# LE SITE CONFRONTE LE PLACEMENT D'UN LOCATAIRE NOMME (2026-10-05, materialisation M2) :
|
||||
# ses valeurs de placement et les ponts de ses machines viennent de sa face publiee.
|
||||
import contexte
|
||||
face = contexte.Locataire.charger(a.locataire).face_publiee()
|
||||
if face is None:
|
||||
print(f"{a.locataire} n'a pas publie sa face reseau.", file=sys.stderr)
|
||||
return 2
|
||||
place, source = dict(face.get("placement") or {}), None
|
||||
ponts = contexte.ponts_publies(face)
|
||||
else:
|
||||
place, source = placement_du_tenant()
|
||||
tenant = nom_du_tenant(source, a.locataire)
|
||||
place = ce_que_le_clonage_utilise(place, underlay_mod.gabarit())
|
||||
if not place:
|
||||
print("Aucun placement declare par le tenant monte — rien a confronter.")
|
||||
print(f"Aucun placement declare par « {tenant} » — rien a confronter.")
|
||||
return 0
|
||||
try:
|
||||
api, depot = Cluster.depuis_hebergeur()
|
||||
|
|
@ -230,7 +280,7 @@ def main() -> int:
|
|||
|
||||
base = source.parents[3] if source else None
|
||||
routage = underlay_mod.routage_tenants(underlay_mod.charger())
|
||||
lignes = constater(api, place, base, routage)
|
||||
lignes = constater(api, place, base, routage, ponts)
|
||||
if a.verifier:
|
||||
mauvais = [l for l in lignes if not l["existe"]]
|
||||
if mauvais:
|
||||
|
|
@ -241,7 +291,7 @@ def main() -> int:
|
|||
print(f"Placement conforme : noeud, stockage, gabarit et reseaux des VM "
|
||||
f"existent sur « {depot.name} ».")
|
||||
return 0
|
||||
return 0 if afficher(lignes, source, depot) else 1
|
||||
return 0 if afficher(lignes, tenant, depot) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -144,17 +144,57 @@ def _cibles(fl: dict) -> list[dict]:
|
|||
return [{"proto": fl.get("protocole", "tcp"), "dport": port} for port in _ports(fl)]
|
||||
|
||||
|
||||
def _face_publiee(nom: str) -> dict | None:
|
||||
"""La face reseau que ce locataire a publiee, ou None (et on le dit)."""
|
||||
try:
|
||||
import contexte
|
||||
from devis_reseau import DOSSIER_INSTANCES
|
||||
face = contexte.Locataire(DOSSIER_INSTANCES / nom).face_publiee()
|
||||
except Exception:
|
||||
face = None
|
||||
if face is None:
|
||||
print(f"note : {nom} n'a pas publie sa face reseau — lecture de son inventaire et de "
|
||||
f"ses variables (transition).", file=sys.stderr)
|
||||
return face
|
||||
|
||||
|
||||
def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
||||
"""Le devis, en structure. Un bloc par tenant federe."""
|
||||
flux_par_role = charger_flux()
|
||||
blocs: list[dict] = []
|
||||
|
||||
for nom, pfx, n in tenants:
|
||||
# LA FACE RESEAU D'ABORD (2026-10-05, docs/conception-contextes.md §6, etape 3). Le
|
||||
# locataire publie ses machines, ses groupes, son administration et le verdict de ses
|
||||
# flux conditionnels (`face-reseau.yml`) ; le devis ne lit plus son inventaire ni ses
|
||||
# variables. Sans face publiee (transition), l'ancienne lecture, et on le dit.
|
||||
face = _face_publiee(nom)
|
||||
inv = inventaire_de(nom)
|
||||
if inv is None:
|
||||
if face is None and inv is None:
|
||||
continue
|
||||
etiquette = f"{pfx}{n['index']}"
|
||||
data = yaml.safe_load(inv.read_text(encoding="utf-8")) or {}
|
||||
if face is not None:
|
||||
import contexte
|
||||
data = contexte.inventaire_depuis_face(face)
|
||||
_verdicts = face.get("conditions") or {}
|
||||
_admin = sorted(set((face.get("administration") or {}).get("intrant") or [])
|
||||
| set((face.get("administration") or {}).get("tunnel") or []))
|
||||
|
||||
def applicable(fl, groupe, _v=_verdicts):
|
||||
if not fl.get("seulement_si"):
|
||||
return True
|
||||
return bool((_v.get(groupe) or {}).get(contexte.cle_condition(fl), True))
|
||||
|
||||
def admin_du_tenant(_nom, _a=_admin):
|
||||
return _a
|
||||
else:
|
||||
data = yaml.safe_load(inv.read_text(encoding="utf-8")) or {}
|
||||
_dossier = inv.parent.parent.parent
|
||||
|
||||
def applicable(fl, groupe, _d=_dossier):
|
||||
return flux_applicable(fl, groupe, _d)
|
||||
|
||||
admin_du_tenant = admin_avec_tunnel
|
||||
# Les PLANIFIES comptent : le pare-feu se prepare avant que la VM existe,
|
||||
# sinon le devis changerait a chaque materialisation d'hote.
|
||||
tous = set(_ip_par_hote(data))
|
||||
|
|
@ -179,10 +219,10 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
"role": "Internet (tout sauf RFC 1918) — flux `externe`",
|
||||
"membres": list(INTERNET),
|
||||
}
|
||||
if admin_avec_tunnel(nom):
|
||||
if admin_du_tenant(nom):
|
||||
ipsets[f"t{n['index']}-admin"] = {
|
||||
"role": "reseaux d'administration (intrant nftables_admin_ssh)",
|
||||
"membres": sorted(admin_avec_tunnel(nom)),
|
||||
"membres": sorted(admin_du_tenant(nom)),
|
||||
}
|
||||
for groupe in sorted(_enfants(data)):
|
||||
if not est_groupe_operationnel(groupe):
|
||||
|
|
@ -205,7 +245,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
for fl in flux_par_role.get(groupe, []):
|
||||
if fl.get("sens") != "ingress":
|
||||
continue
|
||||
if not flux_applicable(fl, groupe, inv.parent.parent.parent):
|
||||
if not applicable(fl, groupe):
|
||||
continue
|
||||
pairs = _pairs(fl)
|
||||
# `externe` est saute PAIR PAR PAIR, jamais le flux entier : le SSH du
|
||||
|
|
@ -221,7 +261,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
if "externe" in pairs and not (
|
||||
str(fl.get("protocole", "tcp")).lower() != "icmp" and "22" in _ports(fl)):
|
||||
sources = [(f"t{n['index']}-internet", "internet")]
|
||||
if fl.get("poste", True) and admin_avec_tunnel(nom):
|
||||
if fl.get("poste", True) and admin_du_tenant(nom):
|
||||
sources.append((f"t{n['index']}-admin", "administration (poste)"))
|
||||
for cle_src, via in sources:
|
||||
utilises.add(cle_src)
|
||||
|
|
@ -255,7 +295,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
# rendrait une liste vide et la regle serait sautee. Son IPSet
|
||||
# existe deja (garde anti-lockout SSH) — on s'y refere directement.
|
||||
if pair == "admin":
|
||||
if not admin_avec_tunnel(nom):
|
||||
if not admin_du_tenant(nom):
|
||||
continue
|
||||
utilises.add(cle)
|
||||
for cible in _cibles(fl):
|
||||
|
|
@ -290,7 +330,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
# `_ports` seulement pour TCP/UDP : appele sur un flux ICMP, il rangeait le
|
||||
# type parmi les « ports derives sautes » — un flux pourtant rendu.
|
||||
if (str(fl.get("protocole", "tcp")).lower() != "icmp"
|
||||
and "22" in _ports(fl) and admin_avec_tunnel(nom)):
|
||||
and "22" in _ports(fl) and admin_du_tenant(nom)):
|
||||
utilises.add(f"t{n['index']}-admin")
|
||||
regles.append({
|
||||
"action": "ACCEPT", "sens": "IN",
|
||||
|
|
@ -351,7 +391,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
"ipsets": {k: v for k, v in ipsets.items() if k in utilises},
|
||||
"groupes": groupes,
|
||||
"affectations": affect, "sans_source": sorted(set(sans_source)),
|
||||
"admin": admin_avec_tunnel(nom),
|
||||
"admin": admin_du_tenant(nom),
|
||||
})
|
||||
|
||||
return {"blocs": blocs}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,19 @@ def _plan_de(nom_instance: str) -> dict:
|
|||
return charger_serveurs(p).get("serveurs") or {}
|
||||
|
||||
|
||||
def _face_publiee(nom: str) -> dict | None:
|
||||
"""La face reseau que ce locataire a publiee, ou None (et on le dit)."""
|
||||
try:
|
||||
import contexte
|
||||
face = contexte.Locataire(DOSSIER_INSTANCES / nom).face_publiee()
|
||||
except Exception:
|
||||
face = None
|
||||
if face is None:
|
||||
print(f"note : {nom} n'a pas publie sa face reseau — lecture de son plan (transition).",
|
||||
file=sys.stderr)
|
||||
return face
|
||||
|
||||
|
||||
def pool_actif() -> str:
|
||||
"""Pool du tenant ACTIF — ce que le playbook de clonage passe a la creation.
|
||||
|
||||
|
|
@ -76,12 +89,39 @@ def pool_actif() -> str:
|
|||
return pool_de(instance.resolve().name, int(n["index"]))
|
||||
|
||||
|
||||
def pool_du_locataire(nom: str) -> str:
|
||||
"""Pool d'un locataire NOMME, d'apres l'index de sa face publiee (materialisation M2).
|
||||
|
||||
`pool_actif` lit l'instance MONTEE : appele par `make locataire-creer`, il rangeait les VM
|
||||
hors de tout pool sur le runner du site (aucune instance), et dans le pool d'un AUTRE
|
||||
locataire sur le poste (l'instance liee). Meme derivation (`pool_de`), autre source.
|
||||
"""
|
||||
import contexte
|
||||
face = contexte.Locataire.charger(nom).face_publiee()
|
||||
if face is None or face.get("index") is None:
|
||||
raise SystemExit(f"{nom} n'a pas publie sa face reseau (ou elle ne porte pas d'index).")
|
||||
return pool_de(contexte.Locataire.charger(nom).depot.resolve().name, int(face["index"]))
|
||||
|
||||
|
||||
def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
||||
"""{pools: [{pool, tenant, index, membres: [{nom, vmid, etat}], sans_vmid: []}]}"""
|
||||
blocs = []
|
||||
for nom_instance, _prefixe, nomenclature in tenants:
|
||||
index = int(nomenclature["index"])
|
||||
membres, sans_vmid = [], []
|
||||
# LA FACE RESEAU D'ABORD (2026-10-05, docs/conception-contextes.md §6, etape 3) : le
|
||||
# locataire publie ses machines, leur VMID et leur etat ; le devis ne lit plus son plan.
|
||||
face = _face_publiee(nom_instance)
|
||||
if face is not None:
|
||||
for nom, m in sorted((face.get("machines") or {}).items()):
|
||||
vmid = (m.get("materialisation") or {}).get("proxmox_vmid")
|
||||
if vmid is None:
|
||||
sans_vmid.append(nom)
|
||||
continue
|
||||
membres.append({"nom": nom, "vmid": int(vmid), "etat": str(m.get("etat", "planifie"))})
|
||||
blocs.append({"pool": pool_de(nom_instance, index), "tenant": nom_instance,
|
||||
"index": index, "membres": membres, "sans_vmid": sans_vmid})
|
||||
continue
|
||||
for nom, srv in sorted(_plan_de(nom_instance).items()):
|
||||
_, seq = fonction_seq(nom)
|
||||
derive = deriver_nomenclature(str(srv.get("fonction", "")), seq, nomenclature) or {}
|
||||
|
|
@ -165,6 +205,8 @@ def main(argv: list[str]) -> int:
|
|||
ap.add_argument("--verifier", action="store_true")
|
||||
ap.add_argument("--pool-actif", action="store_true",
|
||||
help="nom du pool du tenant actif (consomme par make creer-vm)")
|
||||
ap.add_argument("--pool-du-locataire", metavar="DEPOT",
|
||||
help="nom du pool d'un locataire nomme, d'apres sa face (make locataire-creer)")
|
||||
ap.add_argument("--pool-site", action="store_true",
|
||||
help="nom invariable du pool du genome (consomme par make site-creer)")
|
||||
args = ap.parse_args(argv)
|
||||
|
|
@ -172,6 +214,9 @@ def main(argv: list[str]) -> int:
|
|||
if args.pool_actif:
|
||||
print(pool_actif())
|
||||
return 0
|
||||
if args.pool_du_locataire:
|
||||
print(pool_du_locataire(args.pool_du_locataire))
|
||||
return 0
|
||||
|
||||
# LE SITE NE DERIVE D'AUCUN INDEX, DONC SON POOL NE SE CALCULE PAS (2026-09-13).
|
||||
#
|
||||
|
|
|
|||
|
|
@ -619,6 +619,20 @@ def decouvrir() -> list[tuple[str, str, dict]]:
|
|||
return tenants
|
||||
|
||||
|
||||
def _nomenclature_publiee(nom: str) -> dict | None:
|
||||
"""Les champs de nomenclature que ce locataire publie dans sa face reseau, ou None."""
|
||||
try:
|
||||
import contexte
|
||||
face = contexte.Locataire(DOSSIER_INSTANCES / nom).face_publiee()
|
||||
except Exception:
|
||||
face = None
|
||||
if face is None or not face.get("nomenclature"):
|
||||
print(f"note : {nom} n'a pas publie sa nomenclature dans sa face reseau — lecture de "
|
||||
f"son plan (transition).", file=sys.stderr)
|
||||
return None
|
||||
return dict(face["nomenclature"])
|
||||
|
||||
|
||||
def decouvrir_du_site() -> list[tuple[str, str, dict]]:
|
||||
"""Les tenants que CE SITE heberge — `decouvrir()` restreint par `underlay.tenants`.
|
||||
|
||||
|
|
@ -645,12 +659,23 @@ def decouvrir_du_site() -> list[tuple[str, str, dict]]:
|
|||
portes = underlay_mod.tenants_du_site(underlay_mod.charger())
|
||||
if portes is None:
|
||||
return tenants
|
||||
connus = {nom for nom, _pfx, _n in tenants}
|
||||
# LA FACE RESEAU D'ABORD (2026-10-05, docs/conception-contextes.md §6, etape 3). Les noms
|
||||
# viennent du site (`underlay.tenants`) ; l'index et les zones, de ce que chaque locataire
|
||||
# PUBLIE (`face-reseau.yml`, champ `nomenclature`) — plus de sa nomenclature. Sans face
|
||||
# publiee (transition), sa nomenclature, et on le dit.
|
||||
par_nom = {nom: (pfx, n) for nom, pfx, n in tenants}
|
||||
retenus = []
|
||||
for nom in portes:
|
||||
if nom not in connus:
|
||||
print(f"ATTENTION : `tenants` de l'underlay nomme « {nom} », "
|
||||
f"qu'aucun dossier frere ne fournit.", file=sys.stderr)
|
||||
retenus = [t for t in tenants if t[0] in set(portes)]
|
||||
n = _nomenclature_publiee(nom)
|
||||
if n is None:
|
||||
if nom not in par_nom:
|
||||
print(f"ATTENTION : `tenants` de l'underlay nomme « {nom} », "
|
||||
f"qu'aucun dossier frere ne fournit.", file=sys.stderr)
|
||||
continue
|
||||
n = par_nom[nom][1]
|
||||
if n.get("index") is not None and n.get("categories") and n.get("federe", True):
|
||||
retenus.append((nom, prefixe(nom), n))
|
||||
retenus.sort(key=lambda t: t[2]["index"])
|
||||
if not retenus:
|
||||
raise SystemExit("Aucun tenant heberge sur ce site : `underlay.tenants` n'en "
|
||||
"retient aucun de connu.")
|
||||
|
|
|
|||
|
|
@ -28,6 +28,13 @@ DEUX GESTES, DEUX COTES :
|
|||
genome_colis.py etat <depot>
|
||||
Des deux cotes. Ce que cette copie porte, pour le confronter a la forge.
|
||||
|
||||
LES ETIQUETTES VOYAGENT AVEC LA BRANCHE (2026-10-07). Le colis ne portait que `main` : les
|
||||
releases n'arrivaient jamais sur la forge du site, qui fait pourtant autorite (D-81).
|
||||
`v2026.08.21` et `v2026.10.07` y manquaient, et il a fallu les pousser a la main. Le colis
|
||||
emporte desormais toutes les etiquettes du poste ; le runner les recoit et les pousse. Une
|
||||
etiquette qui existe deja SOUS UN AUTRE OBJET est refusee, des deux cotes : une release ne
|
||||
bouge pas.
|
||||
|
||||
`--ff-only` est le garde-fou. Si les deux histoires ont diverge, on s'arrete plutot que de
|
||||
fabriquer une fusion que personne n'a decidee — et le message de git nomme alors exactement
|
||||
ce qu'il faut reconcilier.
|
||||
|
|
@ -55,13 +62,18 @@ def _git(depot: Path, *args: str, env: dict | None = None) -> str:
|
|||
return (r.stdout or "").strip()
|
||||
|
||||
|
||||
def etiquettes(depot: Path) -> list[str]:
|
||||
return sorted(e for e in _git(depot, "tag", "-l").splitlines() if e)
|
||||
|
||||
|
||||
def emballer(depot: Path, colis: Path, branche: str) -> int:
|
||||
if not (depot / ".git").exists():
|
||||
raise SystemExit(f"{depot} n'est pas un depot git — rien a emballer.")
|
||||
colis.parent.mkdir(parents=True, exist_ok=True)
|
||||
_git(depot, "bundle", "create", str(colis), branche)
|
||||
_git(depot, "bundle", "create", str(colis), branche, "--tags")
|
||||
print(json.dumps({"depot": str(depot), "colis": str(colis),
|
||||
"tete": _git(depot, "log", "-1", "--format=%H")}))
|
||||
"tete": _git(depot, "log", "-1", "--format=%H"),
|
||||
"etiquettes": etiquettes(depot)}))
|
||||
return 0
|
||||
|
||||
|
||||
|
|
@ -101,6 +113,10 @@ def recevoir(depot: Path, colis: Path, pushurl: str, cle: str | None,
|
|||
f"refs/heads/{branche}:refs/remotes/colis/{branche}")
|
||||
_git(depot, "merge", "--ff-only", f"refs/remotes/colis/{branche}")
|
||||
apres = _git(depot, "log", "-1", "--format=%H")
|
||||
# Sans `--force` : une etiquette deja presente sous un autre objet fait echouer la
|
||||
# recuperation (« would clobber existing tag ») — c'est voulu.
|
||||
if any("refs/tags/" in l for l in _git(depot, "bundle", "list-heads", str(colis)).splitlines()):
|
||||
_git(depot, "fetch", str(colis), "refs/tags/*:refs/tags/*")
|
||||
|
||||
env = None
|
||||
if cle:
|
||||
|
|
@ -114,14 +130,15 @@ def recevoir(depot: Path, colis: Path, pushurl: str, cle: str | None,
|
|||
# seule l'ECRITURE s'authentifie, et seulement le temps de cette commande.
|
||||
r = subprocess.run(
|
||||
["git", "-C", str(depot), "-c", f"remote.origin.pushurl={pushurl}",
|
||||
"push", "origin", branche],
|
||||
"push", "origin", branche, "refs/tags/*:refs/tags/*"],
|
||||
capture_output=True, text=True, env=env)
|
||||
sortie = ((r.stdout or "") + (r.stderr or "")).strip()
|
||||
if r.returncode != 0:
|
||||
raise SystemExit(f"la poussee a echoue :\n {sortie}")
|
||||
|
||||
print(json.dumps({"avant": avant, "apres": apres,
|
||||
"pousse": f"{branche} -> {branche}" in sortie, "sortie": sortie[:400]}))
|
||||
"pousse": f"{branche} -> {branche}" in sortie,
|
||||
"etiquettes": etiquettes(depot), "sortie": sortie[:400]}))
|
||||
return 0
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -87,6 +87,28 @@ def _domaine_interne() -> str:
|
|||
|
||||
|
||||
|
||||
def _fiche_du_site() -> dict | None:
|
||||
"""La fiche que l'hebergeur a deposee chez CE locataire (`fiche-site.yml`), ou None.
|
||||
|
||||
ETAPE 3 DU CHEMIN (docs/conception-contextes.md §6). Trois valeurs de l'inventaire venaient
|
||||
d'une lecture DIRECTE du site, monte sur le poste : la zone a deleguer et ses resolveurs,
|
||||
les serveurs de temps, le mode de routage. Generer sur le runner du locataire, qui n'a pas
|
||||
le depot du site, les aurait perdues. La fiche les porte, et l'inventaire devient le meme
|
||||
ou qu'on le genere. Sans fiche (transition), on lit encore le site, et on le dit.
|
||||
"""
|
||||
try:
|
||||
import contexte
|
||||
return contexte.Locataire(INSTANCE.resolve()).fiche_du_site()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
FICHE_SITE = _fiche_du_site()
|
||||
if FICHE_SITE is None:
|
||||
print("note : pas de `fiche-site.yml` chez ce locataire — temps, delegation DNS et routage "
|
||||
"lus directement dans le site monte (transition).", file=sys.stderr)
|
||||
|
||||
|
||||
def _zones_deleguees() -> list:
|
||||
"""La zone de l'HEBERGEUR, et l'adresse a qui la demander. [] si aucune carte.
|
||||
|
||||
|
|
@ -116,6 +138,13 @@ def _zones_deleguees() -> list:
|
|||
ne delegue plus rien, donc ne peut plus nommer les services du site. C'est ce qu'on
|
||||
veut CONSTATER d'une emancipation, pas une panne a reparer.
|
||||
"""
|
||||
if FICHE_SITE is not None:
|
||||
gen = FICHE_SITE.get("generation") or {}
|
||||
zone = str(gen.get("zone_site") or "").strip()
|
||||
if not zone or zone == _domaine_interne():
|
||||
return []
|
||||
adresses = [str(a) for a in gen.get("resolveurs_site") or [] if a]
|
||||
return [{"nom": zone, "adresses": adresses}] if adresses else []
|
||||
try:
|
||||
import underlay as underlay_mod
|
||||
zone = str((underlay_mod.lire_plan_site("10-intrants.yml") or {})
|
||||
|
|
@ -240,8 +269,11 @@ def generer() -> dict:
|
|||
# La patte de la frontiere que les tenants traversent — calculee une fois.
|
||||
_temps_frontiere: list = []
|
||||
try:
|
||||
_carte = underlay_mod.charger()
|
||||
if _carte:
|
||||
_carte = None if FICHE_SITE is not None else underlay_mod.charger()
|
||||
if FICHE_SITE is not None:
|
||||
_temps_frontiere = [str(x) for x in (FICHE_SITE.get("generation") or {})
|
||||
.get("serveurs_temps") or []]
|
||||
elif _carte:
|
||||
# LE LIEN DE TRANSIT SE DECLARE, IL NE SE DEVINE PAS. `reseau_transit()`
|
||||
# le derive de `passerelle_sortie` — le champ qui dit « c'est par la qu'on
|
||||
# sort ». Le reconnaitre a son prefixe d'adresse aurait marche ici et menti
|
||||
|
|
@ -275,7 +307,9 @@ def generer() -> dict:
|
|||
#
|
||||
# L'ETIQUETTE DOIT ETRE VIDE en SDN : le VNet la porte deja. En poser une seconde
|
||||
# au niveau de la carte donnerait un double etiquetage.
|
||||
sdn = underlay_mod.routage_tenants(underlay_mod.charger()) == "sdn"
|
||||
sdn = (str((FICHE_SITE.get("generation") or {}).get("routage_tenants")) == "sdn"
|
||||
if FICHE_SITE is not None
|
||||
else underlay_mod.routage_tenants(underlay_mod.charger()) == "sdn")
|
||||
index_tenant = nomenclature.get("index")
|
||||
|
||||
# UNE MACHINE SANS FONCTION DECLAREE N'A PAS D'ADRESSE — ET LE DISAIT PAS (2026-08-24).
|
||||
|
|
|
|||
|
|
@ -4665,6 +4665,258 @@ def preuve_runbooks_suivent_le_makefile() -> tuple[bool, str]:
|
|||
f"chacune portee par un assistant ou exemptee avec son motif.")
|
||||
|
||||
|
||||
def preuve_fiche_du_site() -> tuple[bool, str]:
|
||||
"""La fiche que chaque site destine a ses locataires dit-elle ce qu'ils portent deja ?
|
||||
|
||||
POURQUOI (2026-10-04, `docs/conception-contextes.md` §2.4). Le site informait le locataire
|
||||
par trois canaux, sans fiche : des copies ecrites a la main (index, adresse publique,
|
||||
intrants, racine), une lecture DIRECTE du site par l'instancier (temps, delegation DNS,
|
||||
routage SDN), et le genome. La fiche les reunit. Avant qu'un seul consommateur bascule sur
|
||||
elle (etape 3), elle doit dire EXACTEMENT ce que ces canaux ont depose : sinon la bascule
|
||||
changerait la flotte au lieu de la decrire.
|
||||
|
||||
Pour chaque couple (site, locataire) dont le site est l'hebergeur ACTIF : ecarts entre
|
||||
`site.fiche_pour(locataire)` et les copies du locataire, sa racine, et son inventaire
|
||||
genere. `scripts/tests/test_contexte.py` prouve que chaque sorte d'ecart est vue.
|
||||
"""
|
||||
sys.path.insert(0, str(RACINE / "scripts"))
|
||||
import contexte as _ctx
|
||||
couples = _ctx.couples_hebergeur()
|
||||
if not couples:
|
||||
return True, "Aucun site frere : aucune fiche a confronter."
|
||||
ecarts = [f"{s.nom} -> {l.nom} : {e}" for s, l in couples for e in _ctx.verifier_fiche(s, l)]
|
||||
if ecarts:
|
||||
return False, (f"{len(ecarts)} ecart(s) entre la fiche du site et ce que le locataire "
|
||||
f"porte : " + " | ".join(ecarts[:4]))
|
||||
return True, (f"{len(couples)} fiche(s) de site : chacune dit exactement ce que son locataire "
|
||||
f"porte (copies, racine, inventaire genere).")
|
||||
|
||||
|
||||
def preuve_face_reseau() -> tuple[bool, str]:
|
||||
"""La face reseau que chaque locataire publie dit-elle ce que les consommateurs du site en
|
||||
tirent aujourd'hui, en lisant ses fichiers internes ?
|
||||
|
||||
POURQUOI (2026-10-04, `docs/conception-contextes.md` §2.4). Le site lisait six fichiers
|
||||
internes de ses locataires — nomenclature, inventaire, `group_vars`, acces, domaines. La
|
||||
face reseau les publie, calculee chez le locataire. Avant que le site la lise a leur place
|
||||
(etape 3), elle doit dire EXACTEMENT ce que la decouverte, le devis Proxmox, la frontiere
|
||||
et l'inventaire du site en tirent. Les flux resolus suivront ; ils ne sont pas encore la.
|
||||
"""
|
||||
sys.path.insert(0, str(RACINE / "scripts"))
|
||||
import contexte as _ctx
|
||||
couples = _ctx.couples_hebergeur()
|
||||
if not couples:
|
||||
return True, "Aucun site frere : aucune face reseau a confronter."
|
||||
ecarts = [f"{l.nom} -> {s.nom} : {e}" for s, l in couples for e in _ctx.verifier_face(s, l)]
|
||||
if ecarts:
|
||||
return False, (f"{len(ecarts)} ecart(s) entre la face reseau et ce que le site en tire : "
|
||||
+ " | ".join(ecarts[:4]))
|
||||
return True, (f"{len(couples)} face(s) reseau : chacune dit exactement ce que le site en tire "
|
||||
f"(decouverte, devis Proxmox, frontiere, DNS public, sauvegarde).")
|
||||
|
||||
|
||||
def preuve_flux_concordent() -> tuple[bool, str]:
|
||||
"""Le pare-feu de chaque machine (nftables, resolu par le locataire) et celui de Proxmox
|
||||
(devise par le site) admettent-ils les memes entrees ?
|
||||
|
||||
POURQUOI (2026-10-04, `docs/conception-contextes.md` §2.4, constat 2). Les flux etaient
|
||||
calcules DEUX FOIS, par le locataire et par le site, chacun avec sa version du moteur. La
|
||||
face reseau publie ceux du locataire (`flux-genere/*.connectivite.json`). Machine par
|
||||
machine et port par port, les sources admises doivent etre les memes, une fois neutralisees
|
||||
les differences d'ecriture (decrites dans `contexte.py`). Premiere mesure : 3 ecarts par
|
||||
locataire, tous sur le courriel — le locataire jetait les clients internes d'un port aussi
|
||||
public ; il les publie desormais (`sources_declarees`).
|
||||
"""
|
||||
sys.path.insert(0, str(RACINE / "scripts"))
|
||||
import contexte as _ctx
|
||||
couples = _ctx.couples_hebergeur()
|
||||
if not couples:
|
||||
return True, "Aucun site frere : aucun flux a confronter."
|
||||
ecarts = [f"{l.nom} : {e}" for s, l in couples for e in _ctx.verifier_flux(s, l)]
|
||||
if ecarts:
|
||||
return False, (f"{len(ecarts)} ecart(s) entre le pare-feu des machines et celui de Proxmox : "
|
||||
+ " | ".join(ecarts[:4]))
|
||||
return True, (f"{len(couples)} locataire(s) : machine par machine et port par port, nftables et "
|
||||
f"Proxmox admettent les memes sources.")
|
||||
|
||||
|
||||
def preuve_frontiere_identites() -> tuple[bool, str]:
|
||||
"""Les alias, les routes et la traduction sortante par lesquels la frontiere designe chaque
|
||||
locataire disent-ils ce que sa face reseau et la fiche du site portent ?
|
||||
|
||||
POURQUOI (2026-10-04, `docs/conception-contextes.md` §2.4). La frontiere est le dernier
|
||||
consommateur du site qui lit l'interieur des locataires. Premier temps de sa bascule : les
|
||||
identites — supernet (ses zones), administration par interface, tunnel, un alias par groupe
|
||||
qui a des flux de bordure, une route par zone, la sortie par l'adresse publique attribuee.
|
||||
Les entrees publiques et les sorties suivront.
|
||||
"""
|
||||
sys.path.insert(0, str(RACINE / "scripts"))
|
||||
import contexte as _ctx
|
||||
couples = _ctx.couples_hebergeur()
|
||||
if not couples:
|
||||
return True, "Aucun site frere : aucune frontiere a confronter."
|
||||
ecarts = [f"{l.nom} : {e}" for s, l in couples for e in _ctx.verifier_frontiere(s, l)]
|
||||
if ecarts:
|
||||
return False, (f"{len(ecarts)} ecart(s) entre la frontiere et ce que le locataire publie : "
|
||||
+ " | ".join(ecarts[:4]))
|
||||
return True, (f"{len(couples)} locataire(s) : la frontiere les designe par les identites qu'ils "
|
||||
f"publient (supernet, administration, tunnel, groupes, routes, sortie).")
|
||||
|
||||
|
||||
def preuve_frontiere_entrees_publiques() -> tuple[bool, str]:
|
||||
"""Les regles du WAN, les redirections et le tunnel d'administration d'un locataire disent-ils
|
||||
ce que sa face reseau ouvre a tous ?
|
||||
|
||||
POURQUOI (2026-10-04, `docs/conception-contextes.md` §2.4). Second temps de la frontiere.
|
||||
Machine par machine, chaque entree ouverte a tous (`0.0.0.0/0`) dans la face doit avoir sa
|
||||
regle WAN et, si elle a un port, sa redirection depuis l'adresse que la fiche du site
|
||||
attribue — et rien de plus. Le port public d'un locataire est son port local ; s'il en
|
||||
differait, la face ne le porterait pas, et la preuve le dirait.
|
||||
"""
|
||||
sys.path.insert(0, str(RACINE / "scripts"))
|
||||
import contexte as _ctx
|
||||
couples = _ctx.couples_hebergeur()
|
||||
if not couples:
|
||||
return True, "Aucun site frere : aucune entree publique a confronter."
|
||||
ecarts = [f"{l.nom} : {e}" for s, l in couples for e in _ctx.verifier_entrees_publiques(s, l)]
|
||||
if ecarts:
|
||||
return False, (f"{len(ecarts)} ecart(s) entre la frontiere et ce que le locataire ouvre a tous : "
|
||||
+ " | ".join(ecarts[:4]))
|
||||
return True, (f"{len(couples)} locataire(s) : regles WAN, redirections et tunnel disent exactement "
|
||||
f"ce que leur face reseau ouvre a tous.")
|
||||
|
||||
|
||||
def preuve_frontiere_administration() -> tuple[bool, str]:
|
||||
"""Par la gestion, le VPN et le tunnel, l'administration atteint-elle chez chaque locataire
|
||||
exactement ce que sa face reseau lui ouvre ?
|
||||
|
||||
POURQUOI (2026-10-05, `docs/conception-contextes.md` §2.4). Troisieme temps de la frontiere,
|
||||
premiere moitie. Le SSH, les entrees qui nomment l'administration parmi leurs sources, et
|
||||
les entrees publiques que leur flux ouvre au poste (`poste`, faux pour le 25 de Postfix).
|
||||
Le tunnel atteint toute la flotte, sur les ports que la politique du site lui accorde.
|
||||
"""
|
||||
sys.path.insert(0, str(RACINE / "scripts"))
|
||||
import contexte as _ctx
|
||||
couples = _ctx.couples_hebergeur()
|
||||
if not couples:
|
||||
return True, "Aucun site frere : aucune administration a confronter."
|
||||
ecarts = [f"{l.nom} : {e}" for s, l in couples for e in _ctx.verifier_administration(s, l)]
|
||||
if ecarts:
|
||||
return False, (f"{len(ecarts)} ecart(s) entre la frontiere et ce que la face ouvre a "
|
||||
f"l'administration : " + " | ".join(ecarts[:4]))
|
||||
return True, (f"{len(couples)} locataire(s) : par la gestion, le VPN et le tunnel, "
|
||||
f"l'administration atteint exactement ce que leur face reseau lui ouvre.")
|
||||
|
||||
|
||||
def preuve_frontiere_sorties() -> tuple[bool, str]:
|
||||
"""Les regles de sortie d'un locataire vers l'Internet disent-elles ce que sa face reseau
|
||||
publie (`sorties_externes`) ?
|
||||
|
||||
POURQUOI (2026-10-05, `docs/conception-contextes.md` §2.4). Dernier temps de la frontiere.
|
||||
Le locataire ne relevait pas ses sorties (ses machines les acceptent toutes) ; il les publie
|
||||
desormais, et la frontiere doit les laisser passer, elles et rien d'autre. En chemin, deux
|
||||
sorties de `serveur_ops` se sont revelees fausses et ont ete retirees (85, 87).
|
||||
"""
|
||||
sys.path.insert(0, str(RACINE / "scripts"))
|
||||
import contexte as _ctx
|
||||
couples = _ctx.couples_hebergeur()
|
||||
if not couples:
|
||||
return True, "Aucun site frere : aucune sortie a confronter."
|
||||
ecarts = [f"{l.nom} : {e}" for s, l in couples for e in _ctx.verifier_sorties(s, l)]
|
||||
if ecarts:
|
||||
return False, (f"{len(ecarts)} ecart(s) entre la frontiere et ce que le locataire publie comme "
|
||||
f"sorties : " + " | ".join(ecarts[:4]))
|
||||
return True, (f"{len(couples)} locataire(s) : la frontiere laisse sortir exactement ce que leur "
|
||||
f"face reseau publie.")
|
||||
|
||||
|
||||
def preuve_inventaire_portable() -> tuple[bool, str]:
|
||||
"""Chaque locataire porte-t-il la fiche a jour de son site, et son inventaire se genere-t-il
|
||||
SANS le site, a l'octet pres ?
|
||||
|
||||
POURQUOI (2026-10-05, `docs/conception-contextes.md` §6, etape 3). L'instancier lisait le site
|
||||
monte sur le poste pour trois valeurs (temps, delegation DNS, routage) ; genere ailleurs,
|
||||
l'inventaire les perdait. Il lit desormais la fiche que le site depose. Deux conditions : la
|
||||
fiche deposee est celle que le site calcule aujourd'hui ; et l'inventaire genere sans le site
|
||||
est l'inventaire versionne. C'est la portabilite d'un locataire, prouvee.
|
||||
"""
|
||||
sys.path.insert(0, str(RACINE / "scripts"))
|
||||
import contexte as _ctx
|
||||
couples = _ctx.couples_hebergeur()
|
||||
if not couples:
|
||||
return True, "Aucun site frere : aucune fiche deposee a confronter."
|
||||
ecarts = []
|
||||
for s, l in couples:
|
||||
ecarts += [f"{l.nom} : {e}" for e in _ctx.verifier_fiche_deposee(s, l)]
|
||||
ecarts += [f"{l.nom} : {e}" for e in _ctx.verifier_inventaire_sans_site(l)]
|
||||
if ecarts:
|
||||
return False, f"{len(ecarts)} ecart(s) : " + " | ".join(ecarts[:4])
|
||||
return True, (f"{len(couples)} locataire(s) : fiche du site deposee a jour, et inventaire genere "
|
||||
f"sans le site identique a l'octet pres.")
|
||||
|
||||
|
||||
def preuve_face_publiee() -> tuple[bool, str]:
|
||||
"""Chaque locataire a-t-il publie (`face-reseau.yml`) la face reseau qu'il calcule aujourd'hui ?
|
||||
|
||||
POURQUOI (2026-10-05, `docs/conception-contextes.md` §6, etape 3). Les consommateurs du site
|
||||
lisent desormais ce fichier au lieu des fichiers internes du locataire. Une face perimee leur
|
||||
ferait poser une configuration fausse ; une face absente les ferait retomber sur l'ancienne
|
||||
lecture. Les deux sont refusees ici. A republier apres `make instancier` ou `make flux`.
|
||||
"""
|
||||
sys.path.insert(0, str(RACINE / "scripts"))
|
||||
import contexte as _ctx
|
||||
locataires = sorted({l for _s, l in _ctx.couples_hebergeur()}, key=lambda l: l.nom)
|
||||
if not locataires:
|
||||
return True, "Aucun locataire frere : aucune face publiee a confronter."
|
||||
ecarts = [f"{l.nom} : {e}" for l in locataires for e in _ctx.verifier_face_publiee(l)]
|
||||
if ecarts:
|
||||
return False, f"{len(ecarts)} ecart(s) : " + " | ".join(ecarts[:4])
|
||||
return True, f"{len(locataires)} locataire(s) : face reseau publiee, a jour."
|
||||
|
||||
|
||||
def preuve_clonage_publie() -> tuple[bool, str]:
|
||||
"""Chaque machine publiee porte-t-elle les parametres de clonage que rend
|
||||
`parametres-proxmox` sur l'inventaire du locataire ?
|
||||
|
||||
POURQUOI (2026-10-05, `docs/conception-contextes.md`, materialisation M1). Le runner du site
|
||||
va cloner les VM d'un locataire d'apres sa face, sans monter son depot. Elle doit donc porter
|
||||
exactement ce que `make creer-vm` evalue aujourd'hui : VMID, adresse, pont, ressources, noeud,
|
||||
DNS, domaine, cles d'amorcage (publiques). Compare a la commande meme, lancee a part.
|
||||
"""
|
||||
sys.path.insert(0, str(RACINE / "scripts"))
|
||||
import contexte as _ctx
|
||||
locataires = sorted({l for _s, l in _ctx.couples_hebergeur()}, key=lambda l: l.nom)
|
||||
if not locataires:
|
||||
return True, "Aucun locataire frere : aucun parametre de clonage a confronter."
|
||||
ecarts = [f"{l.nom} : {e}" for l in locataires for e in _ctx.verifier_clonage(l)]
|
||||
if ecarts:
|
||||
return False, f"{len(ecarts)} ecart(s) : " + " | ".join(ecarts[:4])
|
||||
n = sum(len((l.face_publiee() or {}).get("machines") or {}) for l in locataires)
|
||||
return True, f"{n} machine(s) : la face publie exactement les parametres de clonage de l'inventaire."
|
||||
|
||||
|
||||
def preuve_materialisation_par_la_face() -> tuple[bool, str]:
|
||||
"""Les commandes du site qui nomment leur locataire tirent-elles de sa face les memes
|
||||
intrants que l'ancien chemin ?
|
||||
|
||||
POURQUOI (2026-10-05, `docs/conception-contextes.md`, materialisation M2). `locataire-creer`,
|
||||
`locataire-raser` et `placement-plan TENANT=` ne montent plus le depot du locataire : ils
|
||||
lisent sa face. Creer, raser et placer doivent donc viser exactement les memes machines,
|
||||
VMID, valeurs et ponts que `flotte-creer`, `raser` et `placement-plan` sur l'instance montee
|
||||
— chacun mesure par la commande de l'ancien chemin, lancee a part.
|
||||
"""
|
||||
sys.path.insert(0, str(RACINE / "scripts"))
|
||||
import contexte as _ctx
|
||||
locataires = sorted({l for _s, l in _ctx.couples_hebergeur()}, key=lambda l: l.nom)
|
||||
if not locataires:
|
||||
return True, "Aucun locataire frere : aucune materialisation a confronter."
|
||||
ecarts = [f"{l.nom} : {e}" for l in locataires for e in _ctx.verifier_materialisation(l)]
|
||||
if ecarts:
|
||||
return False, f"{len(ecarts)} ecart(s) : " + " | ".join(ecarts[:4])
|
||||
return True, (f"{len(locataires)} locataire(s) : creer, raser et placer visent par la face "
|
||||
f"les memes machines que par l'instance montee.")
|
||||
|
||||
|
||||
PREUVES: list[dict] = [
|
||||
{"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"],
|
||||
"cmds": [["ansible-lint", "-q"]]},
|
||||
|
|
@ -4851,6 +5103,28 @@ PREUVES: list[dict] = [
|
|||
"refs": [], "func": preuve_dns_public_tient},
|
||||
{"id": "P83", "titre": "Assistants : le registre des runbooks ne prend pas de retard sur le Makefile",
|
||||
"refs": [], "func": preuve_runbooks_suivent_le_makefile},
|
||||
{"id": "P84", "titre": "La fiche du site dit exactement ce que chaque locataire porte",
|
||||
"refs": [], "func": preuve_fiche_du_site},
|
||||
{"id": "P85", "titre": "La face reseau du locataire dit exactement ce que le site en tire",
|
||||
"refs": [], "func": preuve_face_reseau},
|
||||
{"id": "P86", "titre": "Machine par machine, le locataire et Proxmox admettent les memes entrees",
|
||||
"refs": [], "func": preuve_flux_concordent},
|
||||
{"id": "P87", "titre": "La frontiere designe chaque locataire par les identites qu'il publie",
|
||||
"refs": [], "func": preuve_frontiere_identites},
|
||||
{"id": "P88", "titre": "Ce qui entre depuis l'Internet chez un locataire, la frontiere le tient de lui",
|
||||
"refs": [], "func": preuve_frontiere_entrees_publiques},
|
||||
{"id": "P89", "titre": "Ce que l'administration atteint chez un locataire, la frontiere le tient de lui",
|
||||
"refs": [], "func": preuve_frontiere_administration},
|
||||
{"id": "P90", "titre": "Ce qui sort d'un locataire vers l'Internet, la frontiere le tient de lui",
|
||||
"refs": [], "func": preuve_frontiere_sorties},
|
||||
{"id": "P91", "titre": "La fiche du site est deposee a jour, et l'inventaire se genere sans le site",
|
||||
"refs": [], "func": preuve_inventaire_portable},
|
||||
{"id": "P92", "titre": "Chaque locataire a publie sa face reseau a jour",
|
||||
"refs": [], "func": preuve_face_publiee},
|
||||
{"id": "P93", "titre": "La face publie, machine par machine, les parametres de clonage de l'inventaire",
|
||||
"refs": [], "func": preuve_clonage_publie},
|
||||
{"id": "P94", "titre": "Creer, raser et placer un locataire par sa face visent les memes machines",
|
||||
"refs": [], "func": preuve_materialisation_par_la_face},
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -16,9 +16,11 @@ liste que `genome-pousser` — lue, pas recopiee) :
|
|||
demande de fusion acceptee, par exemple) : publier sans les integrer les ecraserait
|
||||
sur la forge du site ;
|
||||
2. signaler ce qui n'est pas committe — cela ne part pas ;
|
||||
3. pousser sur eregion ;
|
||||
puis `make genome-pousser`, puis `make genome-etat` : la forge du site doit porter la meme
|
||||
tete que le poste, sinon on le dit.
|
||||
3. pousser sur eregion, la branche ET les etiquettes (2026-10-07 : les releases n'allaient
|
||||
nulle part d'elles-memes ; une etiquette deplacee est refusee, une release ne bouge pas) ;
|
||||
puis `make genome-pousser`, qui porte aussi les etiquettes et verifie que la forge les a,
|
||||
puis `make genome-etat` : la forge du site doit porter la meme tete que le poste, sinon on
|
||||
le dit.
|
||||
|
||||
make publier tous les depots du genome
|
||||
make publier DEPOT=ops-chezlepro
|
||||
|
|
@ -81,7 +83,7 @@ def main() -> int:
|
|||
if sale:
|
||||
print(f" ~ {d['depot']:<17} {len(sale)} changement(s) NON committe(s) — ils ne partent pas")
|
||||
avance = git(depot, "rev-list", "--count", f"{distant}..HEAD").stdout.strip() or "?"
|
||||
r = git(depot, "push", "-q", "eregion", branche)
|
||||
r = git(depot, "push", "-q", "eregion", branche, "refs/tags/*:refs/tags/*")
|
||||
if r.returncode != 0:
|
||||
refus.append(d["depot"])
|
||||
print(f" !! {d['depot']:<17} push refuse : {r.stderr.strip()[:120]}")
|
||||
|
|
|
|||
|
|
@ -59,6 +59,28 @@ def instance_active() -> tuple[Path, str]:
|
|||
return reel, nom_court(reel)
|
||||
|
||||
|
||||
def couples_publies(locataire: str) -> tuple[Path, list[tuple[str, int]]]:
|
||||
"""(dossier, [(nom d'hote, vmid)]) d'apres la face reseau que le locataire publie."""
|
||||
import contexte
|
||||
loc = contexte.Locataire.charger(locataire)
|
||||
face = loc.face_publiee()
|
||||
if face is None:
|
||||
raise SystemExit(f"{locataire} n'a pas publie sa face reseau : rien a raser par elle.")
|
||||
return loc.depot, contexte.couples_a_raser(face)
|
||||
|
||||
|
||||
def entete(nom: str, base: Path, nomme: bool) -> str:
|
||||
"""Ce que le devis vise, et COMMENT il le designe (2026-10-07).
|
||||
|
||||
Le chemin `--locataire` annoncait « Ecosysteme monte » sur le runner du site, ou rien
|
||||
n'est monte : le locataire y est NOMME, et sa face publiee fait foi. Un devis qui raconte
|
||||
une autre provenance que la sienne fait douter de tout le reste de la liste.
|
||||
"""
|
||||
if nomme:
|
||||
return f"Locataire nommé : « {nom} » ({base}), d'après sa face publiée"
|
||||
return f"Écosystème monté : « {nom} » ({base})"
|
||||
|
||||
|
||||
def plan_derive(base: Path) -> list[tuple[str, int]]:
|
||||
"""(nom d'hote, vmid) tels que le PLAN les derive — la seule source légitime."""
|
||||
env = dict(os.environ, SETOPS_INSTANCE=str(base))
|
||||
|
|
@ -102,10 +124,19 @@ def main(argv: list[str] | None = None) -> int:
|
|||
ap.add_argument("--instance", help="nom de l'ecosysteme a raser (doit correspondre au monte)")
|
||||
ap.add_argument("--hote", help="ne raser QUE cet hote (les quatre verrous s'appliquent)")
|
||||
ap.add_argument("--confirmer", action="store_true")
|
||||
ap.add_argument("--locataire", help="le locataire NOMME, d'apres sa face reseau publiee "
|
||||
"(le site, sans monter son depot)")
|
||||
a = ap.parse_args(argv)
|
||||
|
||||
base, nom_actif = instance_active()
|
||||
couples = plan_derive(base)
|
||||
if a.locataire:
|
||||
# LE SITE RASE UN LOCATAIRE NOMME (2026-10-05, materialisation M2) : ses machines et leurs
|
||||
# VMID viennent de sa face publiee, sans monter son depot. Les quatre verrous restent :
|
||||
# le nom court a taper en derive, comme pour l'instance montee.
|
||||
base, couples = couples_publies(a.locataire)
|
||||
nom_actif = nom_court(base)
|
||||
else:
|
||||
base, nom_actif = instance_active()
|
||||
couples = plan_derive(base)
|
||||
if a.hote:
|
||||
# Les quatre verrous restent en vigueur : on ne fait que RESTREINDRE la liste
|
||||
# derivee du plan. Raser un seul hote sert a eprouver un gabarit ou a reprendre
|
||||
|
|
@ -134,7 +165,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||
else:
|
||||
presentes.append((nom, vmid, v.get("node")))
|
||||
|
||||
print(f"Écosystème monté : « {nom_actif} » ({base})\n")
|
||||
print(entete(nom_actif, base, bool(a.locataire)) + "\n")
|
||||
for nom, vmid, noeud in presentes:
|
||||
print(f" À DÉTRUIRE {vmid} {nom:16} sur {noeud}")
|
||||
for nom, vmid in absentes:
|
||||
|
|
@ -155,12 +186,13 @@ def main(argv: list[str] | None = None) -> int:
|
|||
if not a.confirmer:
|
||||
print(f"\n{len(presentes)} VM seraient DETRUITES, disques compris.\n"
|
||||
f"Relancer avec : make raser CONFIRMER=true INSTANCE={nom_actif}"
|
||||
+ (f" TENANT={a.locataire}" if a.locataire else "")
|
||||
+ (f" HOTE={a.hote}" if a.hote else ""))
|
||||
return 0
|
||||
|
||||
if (a.instance or "").lower() != nom_actif:
|
||||
print(f"\nREFUS : il faut NOMMER l'ecosysteme a raser.\n"
|
||||
f"Monte : « {nom_actif} » ; recu : « {a.instance or '(rien)'} ».\n"
|
||||
f"{'Nomme' if a.locataire else 'Monte'} : « {nom_actif} » ; recu : « {a.instance or '(rien)'} ».\n"
|
||||
f"Taper le nom est ce qui distingue le POC de la production.")
|
||||
return 2
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ pas les secrets du locataire, et c'est voulu : l'armement reste un geste du post
|
|||
|
||||
LES ETAPES, dans l'ordre (`--depuis` reprend a l'une d'elles apres un arret) :
|
||||
|
||||
sauvegarder poste deposer l'etat de chaque noeud MAINTENANT (sinon on perd la journee)
|
||||
sauvegarder poste deposer l'etat de chaque noeud MAINTENANT (sinon on perd la journee),
|
||||
etiquete « avant-raser » : garde jusqu'a la reconstruction suivante
|
||||
raser runner du site detruire les VM
|
||||
creer runner du site recreer les VM depuis le gabarit
|
||||
inseminer runner du site amorcer le runner du locataire (socle + moteur, sans secret)
|
||||
|
|
@ -22,7 +23,8 @@ LES ETAPES, dans l'ordre (`--depuis` reprend a l'une d'elles apres un arret) :
|
|||
monter runner locataire `make monter-flotte` : socle, tout, recette ; chaque role
|
||||
proprietaire d'un etat y remet celui d'avant
|
||||
parefeu poste reactiver le pare-feu Proxmox, VM par VM, runner en dernier
|
||||
bilan poste ce que chaque jeu d'etat est devenu
|
||||
bilan poste ce que chaque jeu d'etat est devenu, puis les TEMOINS : l'etat
|
||||
vivant contre l'instantane d'avant rasage (une perte = arret)
|
||||
|
||||
La premiere etape en echec arrete tout, et le dit. `raser` n'a pas d'autre condition que sa
|
||||
confirmation (decision de l'exploitant) : c'est la PROCEDURE qui sauvegarde avant.
|
||||
|
|
@ -100,7 +102,7 @@ def sur_site(args, commande: str, filtre: tuple[str, ...] = ()) -> int:
|
|||
def site_a_jour(args, tenant: str) -> int:
|
||||
"""Le runner du SITE tire les trois depots qu'il va utiliser, AVANT sa premiere etape.
|
||||
|
||||
Le moteur (Set-OPS-public), le plan du locataire, et le depot du SITE — deduit du lien
|
||||
Le moteur (Set-OPS-public), le depot du locataire (sa face reseau publiee), et le depot du SITE — deduit du lien
|
||||
`underlay.yml`, pas nomme : un autre site s'appelle autrement. Jusqu'au 2026-09-30 seul
|
||||
`raser` tirait, et seulement les deux premiers : une reprise `DEPUIS=creer` partait de
|
||||
ce que le runner avait sous la main, et `flotte-creer` lisait un plan du site jamais tire.
|
||||
|
|
@ -200,12 +202,17 @@ def main() -> int:
|
|||
rc = lancer(["ansible-playbook", "-i", str(inv), "playbooks/backup/restauration.yml",
|
||||
"-e", "restauration_action=sauvegarder"], env=env,
|
||||
filtre=("fatal:", "PLAY RECAP", "failed="))
|
||||
# LE SITE NOMME SON LOCATAIRE, IL NE LE MONTE PLUS (2026-10-05, materialisation M3).
|
||||
# Raser et creer lisaient le depot du locataire monte comme instance sur le runner du
|
||||
# site (`SETOPS_INSTANCE=…/OPS-x`). Ils lisent desormais sa face reseau publiee : memes
|
||||
# VMID, memes parametres de clonage, meme pool, a l'argument pres (P93, P94,
|
||||
# `test_appels_locataire.py`). Le depot du locataire reste tire : sa face y est publiee.
|
||||
elif nom == "raser":
|
||||
rc = sur_site(args, f"SETOPS_INSTANCE={args.racine_runner}/{T} make raser CONFIRMER=true "
|
||||
rc = sur_site(args, f"make locataire-raser TENANT={shlex.quote(T)} CONFIRMER=true "
|
||||
f"INSTANCE={shlex.quote(ecosysteme)}",
|
||||
filtre=("detruite", "VM detruites", "Refus", "REFUS", "ERREUR"))
|
||||
elif nom == "creer":
|
||||
rc = sur_site(args, f"SETOPS_INSTANCE={args.racine_runner}/{T} make flotte-creer "
|
||||
rc = sur_site(args, f"make locataire-creer TENANT={shlex.quote(T)} "
|
||||
f"CONFIRMER=true PARALLELE=4",
|
||||
filtre=("=== Creation", "a ECHOUE", "Toutes les VM", "Refus"))
|
||||
elif nom == "inseminer":
|
||||
|
|
@ -259,7 +266,14 @@ def main() -> int:
|
|||
env=env, filtre=(" ",))
|
||||
else: # bilan
|
||||
rc = lancer(["ansible-playbook", "-i", str(inv), "playbooks/backup/restauration.yml"], env=env,
|
||||
filtre=("jeu ", "fatal:"))
|
||||
filtre=("jeu ", "avant-raser:", "fatal:"))
|
||||
# LES TEMOINS (2026-10-07) : le bilan dit ce que chaque role a FAIT ; les temoins
|
||||
# mesurent ce qui est revenu, contre l'instantane depose a l'etape `sauvegarder`.
|
||||
if rc == 0:
|
||||
rc = lancer(["ansible-playbook", "-i", str(inv), "playbooks/backup/restauration.yml",
|
||||
"-e", "restauration_action=temoins"], env=env,
|
||||
filtre=("TEMOINS", "IDENTIQUE", "CONFORME", "ECART", "SANS OBJET",
|
||||
"perdu", "IDENTITE", "fatal:"))
|
||||
if rc != 0:
|
||||
return arret(nom, rc)
|
||||
dire(f" etape « {nom} » : OK")
|
||||
|
|
|
|||
|
|
@ -785,12 +785,24 @@ def generer_nftables(flux_par_role: dict[str, list[dict]], site: bool = False) -
|
|||
hote_par_ip = {ip: h for h, ip in ip_par_hote.items()}
|
||||
sorties: dict[str, dict[tuple, dict]] = {}
|
||||
entrees: dict[str, list[dict]] = {}
|
||||
# CE QUE CHAQUE VM DEMANDE A L'EXTERIEUR (2026-10-05). Ses machines acceptent tout en
|
||||
# sortie : nftables n'en a pas besoin, et ce generateur ne les relevait donc pas. Mais la
|
||||
# FRONTIERE, elle, filtre ces sorties, et la face reseau du locataire doit les lui dire
|
||||
# (docs/conception-contextes.md). Relevees ICI, avec les memes conditions que les entrees.
|
||||
sorties_externes: dict[str, set[tuple]] = {}
|
||||
for hote in sorted(actifs_noms):
|
||||
groupes = [g for g, membres in _enfants(data).items()
|
||||
if hote in (membres.get("hosts") or {}) and est_groupe_operationnel(g)]
|
||||
regles: list[str] = list(regles_admin)
|
||||
for g in sorted(groupes):
|
||||
for fl in flux_par_role.get(g, []):
|
||||
if fl.get("sens") == "egress" and "externe" in _pairs(fl):
|
||||
if flux_applicable(fl, g, dossier_ecosysteme,
|
||||
(_enfants(data).get(g, {}).get("hosts") or {}).get(hote)):
|
||||
for pt in (fl["port"] if isinstance(fl["port"], list) else [fl["port"]]):
|
||||
sorties_externes.setdefault(hote, set()).add(
|
||||
(str(fl.get("protocole", "tcp")).lower(), str(pt), g))
|
||||
continue
|
||||
if fl.get("sens") != "ingress":
|
||||
continue
|
||||
if not flux_applicable(fl, g, dossier_ecosysteme,
|
||||
|
|
@ -867,9 +879,16 @@ def generer_nftables(flux_par_role: dict[str, list[dict]], site: bool = False) -
|
|||
continue # lo deja accepte
|
||||
regles.append(f" {saddr}{_selecteur_nft(fl['protocole'], port)} accept"
|
||||
f" # {g}: {fl.get('raison','')}")
|
||||
entrees.setdefault(hote, []).append(
|
||||
{"proto": proto, "port": str(port), "role": g,
|
||||
"sources": list(srcs) if saddr else ["0.0.0.0/0"]})
|
||||
entree = {"proto": proto, "port": str(port), "role": g,
|
||||
"sources": list(srcs) if saddr else ["0.0.0.0/0"]}
|
||||
# « TOUT LE MONDE » NE DOIT PAS EFFACER QUI ETAIT NOMME (2026-10-04). L'hote
|
||||
# accepte de partout, et c'est juste pour lui ; mais le pare-feu de Proxmox,
|
||||
# dont l'Internet exclut les plages privees, doit admettre les clients
|
||||
# internes NOMMEMENT (le 25 de Postfix : `client_smtp`). Les jeter ici
|
||||
# privait la face reseau du locataire de ce que le site doit en lire.
|
||||
if not saddr and srcs:
|
||||
entree["sources_declarees"] = sorted(str(s) for s in srcs)
|
||||
entrees.setdefault(hote, []).append(entree)
|
||||
# Testable depuis la source : TCP, et l'ICMP « echo » (ping). L'UDP ne se
|
||||
# « connecte » pas ; les autres types ICMP ne se provoquent pas.
|
||||
if proto == "tcp" or (proto == "icmp" and str(port) == "echo-request"):
|
||||
|
|
@ -907,6 +926,8 @@ def generer_nftables(flux_par_role: dict[str, list[dict]], site: bool = False) -
|
|||
key=lambda x: (x["vers"], x["proto"], x["port"])),
|
||||
"entrees": entrees.get(hote, []),
|
||||
"admin_ssh": list(admin_ssh),
|
||||
"sorties_externes": [{"proto": pr, "port": po, "role": ro}
|
||||
for pr, po, ro in sorted(sorties_externes.get(hote, set()))],
|
||||
}, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
|
||||
ecrits.append(chemin)
|
||||
for orphelin in sorted(list(sortie_dir.glob("*.nft")) + list(sortie_dir.glob("*.connectivite.json"))):
|
||||
|
|
|
|||
|
|
@ -145,6 +145,23 @@ def _charger(nom: str) -> dict:
|
|||
return yaml.safe_load(f.read_text(encoding="utf-8")) or {}
|
||||
|
||||
|
||||
def _face_publiee(depot: Path) -> dict | None:
|
||||
"""La face reseau que ce locataire a publiee (`face-reseau.yml`), ou None.
|
||||
|
||||
Sans elle, les lecteurs retombent sur les fichiers internes du locataire, et le disent : une
|
||||
transition, que P92 empeche de durer (une face absente ou perimee y est un echec).
|
||||
"""
|
||||
try:
|
||||
import contexte
|
||||
face = contexte.Locataire(depot).face_publiee()
|
||||
except Exception:
|
||||
face = None
|
||||
if face is None:
|
||||
print(f"note : {depot.name} n'a pas publie sa face reseau — lecture de ses fichiers "
|
||||
f"internes (transition).", file=sys.stderr)
|
||||
return face
|
||||
|
||||
|
||||
def inventaire() -> dict:
|
||||
u = U.charger()
|
||||
if u is None:
|
||||
|
|
@ -210,13 +227,21 @@ def inventaire() -> dict:
|
|||
except Exception:
|
||||
return out
|
||||
for nom_depot, prefixe, _n in decouverts:
|
||||
gv = (racine_instances / nom_depot / "inventories" / "principal"
|
||||
/ "group_vars" / "serveur_backup.yml")
|
||||
try:
|
||||
cle = str((yaml.safe_load(gv.read_text(encoding="utf-8")) or {})
|
||||
.get("serveur_backup_pubkey") or "").strip()
|
||||
except OSError:
|
||||
cle = ""
|
||||
# LA FACE RESEAU D'ABORD (2026-10-05, docs/conception-contextes.md §6, etape 3). Le
|
||||
# locataire PUBLIE la cle qu'il depose (`face-reseau.yml`) ; le site ne fouille plus
|
||||
# ses `group_vars`, dont le chemin etait ecrit en dur (`principal`). Sans face
|
||||
# publiee (transition), l'ancienne lecture, et on le dit.
|
||||
face = _face_publiee(racine_instances / nom_depot)
|
||||
if face is not None:
|
||||
cle = str((face.get("sauvegarde") or {}).get("cle_publique") or "").strip()
|
||||
else:
|
||||
gv = (racine_instances / nom_depot / "inventories" / "principal"
|
||||
/ "group_vars" / "serveur_backup.yml")
|
||||
try:
|
||||
cle = str((yaml.safe_load(gv.read_text(encoding="utf-8")) or {})
|
||||
.get("serveur_backup_pubkey") or "").strip()
|
||||
except OSError:
|
||||
cle = ""
|
||||
# Un locataire sans cle publique declaree n'est pas une erreur du site :
|
||||
# c'est un tenant qui n'a pas encore de sauvegarde. On ne lui ouvre pas de
|
||||
# compte, et surtout on n'en ouvre pas un SANS cle — il accepterait alors
|
||||
|
|
@ -253,6 +278,22 @@ def inventaire() -> dict:
|
|||
except Exception:
|
||||
return out
|
||||
for nom_depot, _prefixe, _n in decouverts:
|
||||
# LA FACE RESEAU D'ABORD (meme etape 3) : le locataire publie ses zones, celles qui
|
||||
# sont signees, l'adresse et le port de son primaire — l'adresse lue dans SON
|
||||
# inventaire, que le site re-derivait avec la sequence 1 par defaut.
|
||||
face = _face_publiee(racine_instances / nom_depot)
|
||||
if face is not None:
|
||||
dp = face.get("dns_public") or {}
|
||||
if dp.get("zones") and dp.get("primaire"):
|
||||
slug = nom_depot.lower().removeprefix("ops-")
|
||||
out.append({"ecosysteme": nom_depot,
|
||||
"cle_tsig": f"tsig-{slug}",
|
||||
"secret_var": f"vault_tsig_dns_public_{slug.replace('-', '_')}",
|
||||
"primaire": dp["primaire"],
|
||||
"port": dp.get("port"),
|
||||
"zones": list(dp.get("zones") or []),
|
||||
"signees": list(dp.get("signees") or [])})
|
||||
continue
|
||||
plan = racine_instances / nom_depot / "plan"
|
||||
try:
|
||||
doms = (yaml.safe_load((plan / "domaines.yml").read_text(encoding="utf-8"))
|
||||
|
|
|
|||
87
scripts/tests/test_appels_locataire.py
Normal file
87
scripts/tests/test_appels_locataire.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
#!/usr/bin/env python3
|
||||
"""`make creer-vm TENANT=` lance-t-il le MEME clonage que l'ancien chemin, l'instance montee ?
|
||||
|
||||
Compare, machine par machine, la ligne `ansible-playbook` qui part reellement — un faux
|
||||
`ansible-playbook` en tete du PATH la consigne au lieu de cloner. Trois appels par machine :
|
||||
- l'ancien chemin : `SETOPS_INSTANCE=<locataire> make creer-vm HOTE=h` ;
|
||||
- le runner du SITE : aucune instance, `TENANT=<locataire>` ;
|
||||
- le POSTE : l'AUTRE locataire monte, `TENANT=<locataire>`.
|
||||
|
||||
POURQUOI (2026-10-05, materialisation M2). Comparer les parametres de clonage ne suffisait pas :
|
||||
le sous-make `cloner-vm` nommait encore le pool par l'instance MONTEE (`--pool-actif`). Sur le
|
||||
site, la VM serait nee hors de tout pool ; sur le poste, dans le pool de l'autre locataire. Seule
|
||||
la ligne finale montre tout ce qui part, y compris ce qu'aucune liste de parametres ne nomme.
|
||||
|
||||
Les dossiers sont designes par des liens SANS ESPACE : le Makefile prend `SETOPS_INSTANCE`
|
||||
dans des `$(wildcard …)`, qu'une espace coupe en deux (le runner du site vit sous `/opt/setops`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
RACINE = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(RACINE / "scripts"))
|
||||
|
||||
import contexte as C # noqa: E402
|
||||
|
||||
FAUX = "#!/bin/bash\nfor a in \"$@\"; do printf '%s\\n' \"$a\"; done >> \"$CAPTURE\"\n"
|
||||
|
||||
|
||||
def appel(tmp: Path, instance: str, hote: str, tenant: str | None) -> tuple[int, str]:
|
||||
capture = tmp / "capture"
|
||||
capture.write_text("")
|
||||
env = dict(os.environ, CAPTURE=str(capture), SETOPS_INSTANCE=instance,
|
||||
PATH=f"{tmp / 'faux'}:{os.environ['PATH']}")
|
||||
env.pop("SETOPS_INVENTAIRE", None)
|
||||
cmd = ["make", "--no-print-directory", "creer-vm", "ATTENDRE=false", f"HOTE={hote}"]
|
||||
if tenant:
|
||||
cmd.append(f"TENANT={tenant}")
|
||||
r = subprocess.run(cmd, cwd=RACINE, env=env, capture_output=True, text=True,
|
||||
stdin=subprocess.DEVNULL)
|
||||
return r.returncode, capture.read_text()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
couples = C.couples_hebergeur()
|
||||
locataires = sorted({l for _s, l in couples}, key=lambda l: l.nom)
|
||||
if len(locataires) < 2:
|
||||
print("SAUTE il faut deux locataires freres (le cas du poste monte l'autre)")
|
||||
return 0
|
||||
echecs, n = [], 0
|
||||
with tempfile.TemporaryDirectory() as t:
|
||||
tmp = Path(t)
|
||||
(tmp / "faux").mkdir()
|
||||
faux = tmp / "faux" / "ansible-playbook"
|
||||
faux.write_text(FAUX)
|
||||
faux.chmod(0o755)
|
||||
liens = {}
|
||||
for l in locataires:
|
||||
liens[l.nom] = tmp / l.nom
|
||||
liens[l.nom].symlink_to(l.depot.resolve())
|
||||
for l in locataires:
|
||||
autre = next(x for x in locataires if x.nom != l.nom)
|
||||
for h in C.hotes_actifs(l.face_publiee() or {}):
|
||||
rc0, ancien = appel(tmp, str(liens[l.nom]), h, None)
|
||||
rc1, site = appel(tmp, "/nulle/part", h, l.nom)
|
||||
rc2, poste = appel(tmp, str(liens[autre.nom]), h, l.nom)
|
||||
n += 1
|
||||
if rc0 or not ancien:
|
||||
echecs.append(f"{l.nom} {h} : l'ancien chemin n'a rien lance (code {rc0})")
|
||||
elif (rc1, site) != (0, ancien):
|
||||
echecs.append(f"{l.nom} {h} : le site lancerait autre chose (code {rc1})")
|
||||
elif (rc2, poste) != (0, ancien):
|
||||
echecs.append(f"{l.nom} {h} : le poste ({autre.nom} monte) lancerait autre chose")
|
||||
for e in echecs:
|
||||
print("ECHEC " + e)
|
||||
if echecs:
|
||||
return 1
|
||||
print(f"OK {n} machine(s) : site et poste lancent le clonage de l'ancien chemin, a l'argument pres")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
718
scripts/tests/test_contexte.py
Normal file
718
scripts/tests/test_contexte.py
Normal file
|
|
@ -0,0 +1,718 @@
|
|||
#!/usr/bin/env python3
|
||||
"""`contexte.py` : un tronc commun, deux classes — et le contexte actif, nomme ou devine.
|
||||
|
||||
Sur une arborescence FABRIQUEE (un site, deux locataires, un depot ambigu), on verifie :
|
||||
- qu'un depot dit ce qu'il est (site, locataire), et qu'un depot qui dit les deux est refuse ;
|
||||
- ce que chaque classe surcharge : index, voute, inventaire ;
|
||||
- les relations : `site.locataires()` (et les absents, nommes), `locataire.site()` ;
|
||||
- le contexte actif : le fichier `contexte` fait foi ; sans lui, les indices d'avant, et le
|
||||
locataire l'emporte quand les deux sont montes (le poste d'aujourd'hui) ;
|
||||
- un contexte nomme qui ne designe rien est un REFUS, pas un contexte vide.
|
||||
Puis, si les vrais depots sont la : chaque locataire declare par un site porte l'index que ce
|
||||
site lui attribue, et nomme un hebergeur qui existe ; la fiche de chaque hebergeur dit
|
||||
exactement ce que son locataire porte, et une fiche alteree (sept facons) est vue en ecart ; la face
|
||||
reseau de chaque locataire dit ce que les consommateurs du site en tirent, et une face alteree
|
||||
(huit facons) est vue en ecart ; machine par machine, le pare-feu du locataire et celui de
|
||||
Proxmox admettent les memes entrees, et des flux alteres (quatre facons) sont vus en ecart ;
|
||||
la frontiere designe chaque locataire par les memes identites, et une identite alteree (cinq
|
||||
facons) se voit ; ce qui entre depuis l'Internet concorde, et une entree alteree (quatre facons)
|
||||
se voit ; ce que l'administration atteint concorde, et une administration alteree (quatre
|
||||
facons) se voit ; ce qui sort vers l'Internet concorde, et une sortie alteree se voit.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
RACINE = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(RACINE / "scripts"))
|
||||
|
||||
import contexte as C # noqa: E402
|
||||
|
||||
ECHECS: list[str] = []
|
||||
|
||||
|
||||
def verifier(cond: bool, msg: str) -> None:
|
||||
print(("OK " if cond else "ECHEC ") + msg)
|
||||
if not cond:
|
||||
ECHECS.append(msg)
|
||||
|
||||
|
||||
def refuse(fonction, msg: str) -> None:
|
||||
try:
|
||||
fonction()
|
||||
except C.ContexteInvalide:
|
||||
verifier(True, msg)
|
||||
return
|
||||
verifier(False, msg + " (aucun refus)")
|
||||
|
||||
|
||||
def ecrire(chemin: Path, texte: str) -> None:
|
||||
chemin.parent.mkdir(parents=True, exist_ok=True)
|
||||
chemin.write_text(texte, encoding="utf-8")
|
||||
|
||||
|
||||
def fabriquer(d: Path) -> Path:
|
||||
"""Un dossier d'ecosystemes : un moteur, un site, deux locataires, un depot ambigu."""
|
||||
moteur = d / "Moteur"
|
||||
moteur.mkdir()
|
||||
ecrire(d / "SITE-Essai" / "underlay.yml",
|
||||
"underlay:\n index: 9\n tenants:\n OPS-Un: 5\n OPS-Absent: 6\n")
|
||||
ecrire(d / "SITE-Essai" / "underlay.vault.yml", "$ANSIBLE_VAULT;1.1;AES256\n")
|
||||
ecrire(d / "OPS-Un" / "plan" / "nomenclature.yml", "index: 5\n")
|
||||
ecrire(d / "OPS-Un" / "inventories" / "principal" / "hosts.yml", "all: {}\n")
|
||||
ecrire(d / "OPS-Un" / "inventories" / "principal" / "group_vars" / "all" / "vault.yml", "x\n")
|
||||
ecrire(d / "OPS-Un" / "parente.yml",
|
||||
"parente:\n depots:\n hebergeur:\n nom: SITE-Essai\n")
|
||||
ecrire(d / "OPS-Deux" / "plan" / "nomenclature.yml", "index: 7\n")
|
||||
ecrire(d / "OPS-Fantome" / "plan" / "nomenclature.yml", "index: 8\n")
|
||||
ecrire(d / "OPS-Fantome" / "parente.yml",
|
||||
"parente:\n depots:\n hebergeur:\n nom: SITE-Inexistant\n")
|
||||
ecrire(d / "Ambigu" / "underlay.yml", "underlay: {index: 1}\n")
|
||||
ecrire(d / "Ambigu" / "plan" / "nomenclature.yml", "index: 1\n")
|
||||
return moteur
|
||||
|
||||
|
||||
def classes(d: Path) -> None:
|
||||
site = C.Ecosysteme.depuis(d / "SITE-Essai")
|
||||
un = C.Ecosysteme.depuis(d / "OPS-Un")
|
||||
verifier(isinstance(site, C.Site) and site.nature == "site", "un depot a `underlay.yml` est un Site")
|
||||
verifier(isinstance(un, C.Locataire) and un.nature == "locataire",
|
||||
"un depot a nomenclature est un Locataire")
|
||||
refuse(lambda: C.Ecosysteme.depuis(d / "Ambigu"),
|
||||
"un depot qui porte `underlay.yml` ET une nomenclature est refuse")
|
||||
refuse(lambda: C.Ecosysteme.depuis(d / "Moteur"), "un depot qui ne declare rien est refuse")
|
||||
|
||||
verifier(site.index == 9 and un.index == 5, "chaque classe lit SON index (underlay / nomenclature)")
|
||||
verifier(site.voute == d / "SITE-Essai" / "underlay.vault.yml" and site.a_sa_voute(),
|
||||
"la voute d'un site est `underlay.vault.yml`")
|
||||
verifier(un.voute == d / "OPS-Un" / "inventories" / "principal" / "group_vars" / "all" / "vault.yml"
|
||||
and un.a_sa_voute(), "la voute d'un locataire est dans son inventaire")
|
||||
verifier(un.inventaire() == d / "OPS-Un" / "inventories" / "principal" / "hosts.yml",
|
||||
"l'inventaire d'un locataire est son `hosts.yml` genere")
|
||||
verifier(site.inventaire().name == "site_inventaire.py",
|
||||
"l'inventaire d'un site est un script, pas un `hosts.yml`")
|
||||
verifier(un.cle_voute.name == "setops-vault-ops-un", "la cle de voute est nommee d'apres le depot")
|
||||
|
||||
verifier(site.allocations() == {"OPS-Un": 5, "OPS-Absent": 6}, "le site rend les index qu'il attribue")
|
||||
verifier([l.nom for l in site.locataires()] == ["OPS-Un"],
|
||||
"`locataires()` rend les locataires presents")
|
||||
verifier(site.locataires_absents() == ["OPS-Absent"], "un locataire declare sans depot est NOMME absent")
|
||||
verifier(un.site() == site, "`locataire.site()` rend l'hebergeur que nomme `parente.yml`")
|
||||
verifier(C.Locataire(d / "OPS-Deux").site() is None, "sans hebergeur nomme, `site()` rend None")
|
||||
refuse(lambda: C.Locataire(d / "OPS-Fantome").site(), "un hebergeur nomme mais absent est refuse")
|
||||
refuse(lambda: C.Site.charger("OPS-Un", d), "`Site.charger` refuse un locataire")
|
||||
refuse(lambda: C.Locataire.charger("SITE-Essai", d), "`Locataire.charger` refuse un site")
|
||||
|
||||
|
||||
def contexte_actif(d: Path, moteur: Path) -> None:
|
||||
sauve = {k: os.environ.pop(k) for k in ("SETOPS_INSTANCE", "SETOPS_UNDERLAY") if k in os.environ}
|
||||
try:
|
||||
verifier(C.actif(moteur) is None, "rien de monte, rien de nomme : aucun contexte (None)")
|
||||
|
||||
(moteur / "underlay.yml").symlink_to(d / "SITE-Essai" / "underlay.yml")
|
||||
verifier(C.actif(moteur) == C.Site(d / "SITE-Essai"), "le lien `underlay.yml` seul designe le site")
|
||||
|
||||
(moteur / "instance").symlink_to(d / "OPS-Un")
|
||||
verifier(len(C.montes(moteur)) == 2, "les deux liens montes : deux ecosystemes designes")
|
||||
verifier(C.actif(moteur) == C.Locataire(d / "OPS-Un"),
|
||||
"les deux montes : le locataire l'emporte, comme la console d'aujourd'hui")
|
||||
|
||||
os.environ["SETOPS_INSTANCE"] = str(d / "OPS-Deux")
|
||||
verifier(C.actif(moteur) == C.Locataire(d / "OPS-Deux"), "`SETOPS_INSTANCE` passe avant le lien")
|
||||
del os.environ["SETOPS_INSTANCE"]
|
||||
|
||||
fichier = moteur / C.NOM_FICHIER_CONTEXTE
|
||||
fichier.write_text("# le contexte de ce poste\nsite:SITE-Essai\n", encoding="utf-8")
|
||||
verifier(C.actif(moteur) == C.Site(d / "SITE-Essai"),
|
||||
"le fichier `contexte` fait foi, meme quand les liens disent autre chose")
|
||||
fichier.write_text("locataire: OPS-Un\n", encoding="utf-8")
|
||||
verifier(C.actif(moteur) == C.Locataire(d / "OPS-Un"), "`locataire:<depot>` designe le locataire")
|
||||
|
||||
for contenu, msg in (("site:SITE-Inexistant\n", "un site nomme qui n'existe pas"),
|
||||
("locataire:SITE-Essai\n", "une nature qui ne correspond pas au depot"),
|
||||
("hebergeur:SITE-Essai\n", "une nature inconnue"),
|
||||
("site:SITE-Essai\nlocataire:OPS-Un\n", "deux valeurs"),
|
||||
("\n# rien\n", "un fichier vide")):
|
||||
fichier.write_text(contenu, encoding="utf-8")
|
||||
refuse(lambda: C.actif(moteur), f"fichier `contexte` refuse : {msg}")
|
||||
finally:
|
||||
os.environ.pop("SETOPS_INSTANCE", None)
|
||||
os.environ.update(sauve)
|
||||
|
||||
|
||||
def vrais_depots() -> None:
|
||||
"""Sur les depots reels, s'ils sont la : les deux sens de la relation concordent."""
|
||||
sites = [C.Site(p.parent) for p in sorted(C.DOSSIER_ECOSYSTEMES.glob("SITE-*/underlay.yml"))]
|
||||
if not sites:
|
||||
print("SAUTE aucun site frere : rien de reel a confronter")
|
||||
return
|
||||
for site in sites:
|
||||
for loc in site.locataires():
|
||||
attendu = site.allocations().get(loc.nom)
|
||||
verifier(attendu is None or loc.index == attendu,
|
||||
f"{site.nom} attribue {attendu} a {loc.nom}, qui porte l'index {loc.index}")
|
||||
heb = loc.nom_hebergeur()
|
||||
if heb:
|
||||
verifier((C.DOSSIER_ECOSYSTEMES / heb / "underlay.yml").is_file(),
|
||||
f"{loc.nom} nomme l'hebergeur {heb}, qui existe")
|
||||
verifier(not site.locataires_absents(),
|
||||
f"{site.nom} : aucun locataire declare sans depot ({site.locataires_absents() or 'ok'})")
|
||||
|
||||
|
||||
def monter_restaure() -> None:
|
||||
avant = {k: os.environ.get(k) for k in ("SETOPS_INSTANCE", "SETOPS_UNDERLAY")}
|
||||
try:
|
||||
with C._monter(C.Site(Path("/nulle/SITE")), C.Locataire(Path("/nulle/OPS"))):
|
||||
dedans = (os.environ.get("SETOPS_UNDERLAY"), os.environ.get("SETOPS_INSTANCE"))
|
||||
raise RuntimeError("sortie par exception")
|
||||
except RuntimeError:
|
||||
pass
|
||||
apres = {k: os.environ.get(k) for k in ("SETOPS_INSTANCE", "SETOPS_UNDERLAY")}
|
||||
verifier(dedans == ("/nulle/SITE/underlay.yml", "/nulle/OPS") and apres == avant,
|
||||
"`_monter` designe le site et le locataire voulus, puis remet l'environnement, meme sur exception")
|
||||
|
||||
|
||||
def fiches() -> None:
|
||||
"""La fiche du site dit ce que chaque locataire porte — et `verifier_fiche` voit chaque ecart."""
|
||||
import copy
|
||||
couples = C.couples_hebergeur()
|
||||
if not couples:
|
||||
print("SAUTE aucun site frere : aucune fiche a confronter")
|
||||
return
|
||||
for site, loc in couples:
|
||||
e = C.verifier_fiche(site, loc)
|
||||
verifier(not e, f"fiche {site.nom} -> {loc.nom} : identique a ce que le locataire porte"
|
||||
+ (f" ({e[0]})" if e else ""))
|
||||
site, loc = couples[0]
|
||||
juste = site.fiche_pour(loc)
|
||||
alterations = {
|
||||
"index": lambda f: f["attribue"].__setitem__("index", (f["attribue"]["index"] or 0) + 1),
|
||||
"adresse publique": lambda f: f["attribue"].__setitem__("ip_publique", "192.0.2.99"),
|
||||
"intrant de service": lambda f: f["offre"].__setitem__("dns_amorcage", "192.0.2.53"),
|
||||
"racine de confiance": lambda f: f["racine_confiance"].__setitem__("sha256", "0" * 64),
|
||||
"serveurs de temps": lambda f: f["generation"].__setitem__("serveurs_temps", ["192.0.2.123"]),
|
||||
"delegation DNS": lambda f: f["generation"].__setitem__("resolveurs_site", ["192.0.2.1"]),
|
||||
"mode de routage": lambda f: f["generation"].__setitem__(
|
||||
"routage_tenants", "switch" if f["generation"]["routage_tenants"] == "sdn" else "sdn"),
|
||||
}
|
||||
for nom, alterer in alterations.items():
|
||||
f = copy.deepcopy(juste)
|
||||
alterer(f)
|
||||
verifier(bool(C.verifier_fiche(site, loc, f)),
|
||||
f"une fiche alteree ({nom}) est vue en ecart")
|
||||
|
||||
|
||||
def faces() -> None:
|
||||
"""La face reseau dit ce que les consommateurs du site en tirent — et chaque ecart se voit."""
|
||||
import copy
|
||||
couples = C.couples_hebergeur()
|
||||
if not couples:
|
||||
print("SAUTE aucun site frere : aucune face reseau a confronter")
|
||||
return
|
||||
for site, loc in couples:
|
||||
e = C.verifier_face(site, loc)
|
||||
verifier(not e, f"face reseau {loc.nom} -> {site.nom} : identique a ce que le site en tire"
|
||||
+ (f" ({e[0]})" if e else ""))
|
||||
site, loc = couples[0]
|
||||
juste = loc.face_reseau()
|
||||
groupe = next(g for g in ("serveur_postfix", "serveur_nginx", "client_backup") if juste["groupes"].get(g))
|
||||
hote = juste["groupes"][groupe][0]
|
||||
|
||||
def deplacer_machine(f):
|
||||
f["machines"][hote]["ip"] = "192.0.2.10"
|
||||
|
||||
def retirer_du_groupe(f):
|
||||
f["groupes"][groupe] = [h for h in f["groupes"][groupe] if h != hote]
|
||||
|
||||
alterations = {
|
||||
"index": lambda f: f.__setitem__("index", (f["index"] or 0) + 1),
|
||||
"adresse d'une machine": deplacer_machine,
|
||||
f"membres du groupe {groupe}": retirer_du_groupe,
|
||||
"administration declaree": lambda f: f["administration"].__setitem__("intrant", ["192.0.2.0/24"]),
|
||||
"tunnel d'administration": lambda f: f["administration"].__setitem__("tunnel", ["192.0.2.128/25"]),
|
||||
"zones publiques": lambda f: f["dns_public"].__setitem__("zones", ["exemple.invalid"]),
|
||||
"primaire DNS": lambda f: f["dns_public"].__setitem__("primaire", "192.0.2.53"),
|
||||
"cle de sauvegarde": lambda f: f["sauvegarde"].__setitem__("cle_publique", "ssh-ed25519 AAAA-fausse"),
|
||||
}
|
||||
for nom, alterer in alterations.items():
|
||||
f = copy.deepcopy(juste)
|
||||
alterer(f)
|
||||
verifier(bool(C.verifier_face(site, loc, f)), f"une face reseau alteree ({nom}) est vue en ecart")
|
||||
|
||||
|
||||
def flux() -> None:
|
||||
"""Machine par machine, le pare-feu du locataire et celui de Proxmox admettent les memes
|
||||
entrees — et une face dont les flux sont alteres se voit."""
|
||||
import copy
|
||||
couples = C.couples_hebergeur()
|
||||
if not couples:
|
||||
print("SAUTE aucun site frere : aucun flux a confronter")
|
||||
return
|
||||
for site, loc in couples:
|
||||
e = C.verifier_flux(site, loc)
|
||||
verifier(not e, f"flux {loc.nom} : le locataire et Proxmox admettent les memes entrees"
|
||||
+ (f" ({e[0]})" if e else ""))
|
||||
site, loc = couples[0]
|
||||
juste = loc.face_reseau()
|
||||
mta = next(h for h, f in juste["flux"].items()
|
||||
if any(x.get("sources_declarees") for x in f["entrees"]))
|
||||
autre = next(h for h, f in juste["flux"].items()
|
||||
if any(len(x.get("sources") or []) > 1 for x in f["entrees"]))
|
||||
|
||||
def retirer_une_source(f):
|
||||
e = next(x for x in f["flux"][autre]["entrees"] if len(x.get("sources") or []) > 1)
|
||||
e["sources"] = e["sources"][1:]
|
||||
|
||||
def ajouter_un_port(f):
|
||||
f["flux"][autre]["entrees"].append({"proto": "tcp", "port": "6666", "role": "x",
|
||||
"sources": ["192.0.2.66"]})
|
||||
|
||||
def effacer_declarees(f):
|
||||
for x in f["flux"][mta]["entrees"]:
|
||||
x.pop("sources_declarees", None)
|
||||
|
||||
def retirer_admin(f):
|
||||
f["flux"][autre]["administration"] = []
|
||||
|
||||
for nom, alterer in (("une source retiree", retirer_une_source),
|
||||
("un port ajoute", ajouter_un_port),
|
||||
(f"sources declarees effacees sur {mta}", effacer_declarees),
|
||||
("administration retiree", retirer_admin)):
|
||||
f = copy.deepcopy(juste)
|
||||
alterer(f)
|
||||
verifier(bool(C.verifier_flux(site, loc, f)), f"des flux alteres ({nom}) sont vus en ecart")
|
||||
|
||||
|
||||
def frontiere() -> None:
|
||||
"""La frontiere designe chaque locataire par les memes identites que sa face reseau et la
|
||||
fiche du site — et chaque identite alteree se voit."""
|
||||
import copy
|
||||
couples = C.couples_hebergeur()
|
||||
if not couples:
|
||||
print("SAUTE aucun site frere : aucune frontiere a confronter")
|
||||
return
|
||||
for site, loc in couples:
|
||||
e = C.verifier_frontiere(site, loc)
|
||||
verifier(not e, f"frontiere {loc.nom} : alias, routes et traduction sortante concordent"
|
||||
+ (f" ({e[0]})" if e else ""))
|
||||
site, loc = couples[0]
|
||||
face, fiche = loc.face_reseau(), site.fiche_pour(loc)
|
||||
groupe = next(g for g in ("serveur_postfix", "serveur_dovecot", "serveur_nginx") if face["groupes"].get(g))
|
||||
alterations = {
|
||||
"zones": lambda f, fi: f.__setitem__("zones", f["zones"][:-1]),
|
||||
"administration declaree": lambda f, fi: f["administration"].__setitem__("intrant", ["192.0.2.0/24"]),
|
||||
"tunnel": lambda f, fi: f["administration"].__setitem__("tunnel", ["192.0.2.128/25"]),
|
||||
f"membres du groupe {groupe}": lambda f, fi: f["groupes"].__setitem__(groupe, []),
|
||||
"adresse publique attribuee": lambda f, fi: fi["attribue"].__setitem__("ip_publique", "192.0.2.60"),
|
||||
}
|
||||
for nom, alterer in alterations.items():
|
||||
f, fi = copy.deepcopy(face), copy.deepcopy(fiche)
|
||||
alterer(f, fi)
|
||||
verifier(bool(C.verifier_frontiere(site, loc, f, fi)), f"une identite alteree ({nom}) est vue a la frontiere")
|
||||
|
||||
|
||||
def entrees_publiques() -> None:
|
||||
"""Ce qui entre chez chaque locataire depuis l'Internet : la frontiere dit ce que la face
|
||||
ouvre a tous — et chaque alteration se voit."""
|
||||
import copy
|
||||
couples = C.couples_hebergeur()
|
||||
if not couples:
|
||||
print("SAUTE aucun site frere : aucune entree publique a confronter")
|
||||
return
|
||||
for site, loc in couples:
|
||||
e = C.verifier_entrees_publiques(site, loc)
|
||||
verifier(not e, f"entrees publiques {loc.nom} : regles WAN, redirections et tunnel concordent"
|
||||
+ (f" ({e[0]})" if e else ""))
|
||||
site, loc = couples[0]
|
||||
face, fiche = loc.face_reseau(), site.fiche_pour(loc)
|
||||
hote, entree = next((h, e) for h, f in face["flux"].items() for e in f["entrees"]
|
||||
if "0.0.0.0/0" in (e.get("sources") or []) and e.get("proto") == "tcp")
|
||||
autre = next(h for h in face["flux"] if h != hote)
|
||||
|
||||
def fermer(f, fi):
|
||||
for e in f["flux"][hote]["entrees"]:
|
||||
if e.get("port") == entree["port"] and e.get("proto") == "tcp":
|
||||
e["sources"] = ["192.0.2.1"]
|
||||
e.pop("sources_declarees", None)
|
||||
|
||||
alterations = {
|
||||
f"port {entree['port']} de {hote} ferme": fermer,
|
||||
"port public ajoute": lambda f, fi: f["flux"][autre]["entrees"].append(
|
||||
{"proto": "tcp", "port": "8443", "role": "x", "sources": ["0.0.0.0/0"]}),
|
||||
"adresse publique attribuee": lambda f, fi: fi["attribue"].__setitem__("ip_publique", "192.0.2.60"),
|
||||
"port du tunnel": lambda f, fi: f["administration"].__setitem__("port_tunnel", 51999),
|
||||
}
|
||||
for nom, alterer in alterations.items():
|
||||
f, fi = copy.deepcopy(face), copy.deepcopy(fiche)
|
||||
alterer(f, fi)
|
||||
verifier(bool(C.verifier_entrees_publiques(site, loc, f, fi)),
|
||||
f"une entree publique alteree ({nom}) est vue a la frontiere")
|
||||
|
||||
|
||||
def administration() -> None:
|
||||
"""Ce que l'administration atteint par la gestion, le VPN et le tunnel : la frontiere dit ce
|
||||
que la face ouvre — et chaque alteration se voit."""
|
||||
import copy
|
||||
couples = C.couples_hebergeur()
|
||||
if not couples:
|
||||
print("SAUTE aucun site frere : aucune administration a confronter")
|
||||
return
|
||||
for site, loc in couples:
|
||||
e = C.verifier_administration(site, loc)
|
||||
verifier(not e, f"administration {loc.nom} : gestion, VPN et tunnel concordent"
|
||||
+ (f" ({e[0]})" if e else ""))
|
||||
site, loc = couples[0]
|
||||
face = loc.face_reseau()
|
||||
ferme = [(h, e) for h, f in face["flux"].items() for e in f["entrees"] if e.get("poste") is False]
|
||||
verifier(bool(ferme), f"la face porte les entrees publiques fermees au poste ({[(h, e['port']) for h, e in ferme]})")
|
||||
hote_adm = next(h for h, f in face["flux"].items()
|
||||
if any(set(e.get("sources") or []) & set(face["administration"]["intrant"])
|
||||
and "0.0.0.0/0" not in (e.get("sources") or []) for e in f["entrees"]))
|
||||
|
||||
def ouvrir_au_poste(f):
|
||||
h, e0 = ferme[0]
|
||||
for e in f["flux"][h]["entrees"]:
|
||||
if e.get("port") == e0.get("port"):
|
||||
e["poste"] = True
|
||||
|
||||
def retirer_sources_admin(f):
|
||||
for e in f["flux"][hote_adm]["entrees"]:
|
||||
e["sources"] = [s for s in e.get("sources") or [] if s not in face["administration"]["intrant"]]
|
||||
|
||||
alterations = {
|
||||
"une entree fermee au poste, ouverte": ouvrir_au_poste,
|
||||
f"sources d'administration retirees sur {hote_adm}": retirer_sources_admin,
|
||||
"SSH d'administration retire": lambda f: f["flux"][hote_adm].__setitem__("administration", []),
|
||||
"tunnel retire": lambda f: f["administration"].__setitem__("tunnel", []),
|
||||
}
|
||||
for nom, alterer in alterations.items():
|
||||
f = copy.deepcopy(face)
|
||||
alterer(f)
|
||||
verifier(bool(C.verifier_administration(site, loc, f)),
|
||||
f"une administration alteree ({nom}) est vue a la frontiere")
|
||||
|
||||
|
||||
def sorties() -> None:
|
||||
"""Ce qui sort de chez chaque locataire vers l'Internet : la frontiere dit ce que la face
|
||||
publie — et chaque alteration se voit."""
|
||||
import copy
|
||||
couples = C.couples_hebergeur()
|
||||
if not couples:
|
||||
print("SAUTE aucun site frere : aucune sortie a confronter")
|
||||
return
|
||||
for site, loc in couples:
|
||||
e = C.verifier_sorties(site, loc)
|
||||
verifier(not e, f"sorties {loc.nom} : la frontiere laisse sortir exactement ce que la face publie"
|
||||
+ (f" ({e[0]})" if e else ""))
|
||||
site, loc = couples[0]
|
||||
face = loc.face_reseau()
|
||||
hote = next(h for h, f in face["flux"].items() if f.get("sorties_externes"))
|
||||
verifier(sum(len(f.get("sorties_externes") or []) for f in face["flux"].values()) > 0,
|
||||
"la face publie les sorties vers l'Internet de chaque machine")
|
||||
|
||||
def retirer(f):
|
||||
f["flux"][hote]["sorties_externes"] = f["flux"][hote]["sorties_externes"][1:]
|
||||
|
||||
def ajouter(f):
|
||||
f["flux"][hote]["sorties_externes"].append({"proto": "tcp", "port": "6667", "role": "x"})
|
||||
|
||||
for nom, alterer in (("une sortie retiree", retirer), ("une sortie ajoutee", ajouter)):
|
||||
f = copy.deepcopy(face)
|
||||
alterer(f)
|
||||
verifier(bool(C.verifier_sorties(site, loc, f)), f"une sortie alteree ({nom}) est vue a la frontiere")
|
||||
|
||||
|
||||
def fiche_deposee() -> None:
|
||||
"""La fiche deposee est a jour, l'inventaire se genere sans le site — et une fiche deposee
|
||||
alteree se voit."""
|
||||
couples = C.couples_hebergeur()
|
||||
if not couples:
|
||||
print("SAUTE aucun site frere : aucune fiche deposee a confronter")
|
||||
return
|
||||
for site, loc in couples:
|
||||
e = C.verifier_fiche_deposee(site, loc)
|
||||
verifier(not e, f"{loc.nom} porte la fiche que {site.nom} lui destine" + (f" ({e[0]})" if e else ""))
|
||||
e = C.verifier_inventaire_sans_site(loc)
|
||||
verifier(not e, f"{loc.nom} : l'inventaire se genere sans le site, a l'octet pres"
|
||||
+ (f" ({e[0]})" if e else ""))
|
||||
site, loc = couples[0]
|
||||
chemin = loc.depot / C.NOM_FICHIER_FICHE_SITE
|
||||
original = chemin.read_text(encoding="utf-8")
|
||||
try:
|
||||
chemin.write_text(original.replace("10.0.4.1", "192.0.2.1"), encoding="utf-8")
|
||||
verifier(bool(C.verifier_fiche_deposee(site, loc)), "une fiche deposee alteree est vue perimee")
|
||||
verifier(bool(C.verifier_inventaire_sans_site(loc)),
|
||||
"une fiche deposee alteree change l'inventaire genere : la preuve le voit")
|
||||
finally:
|
||||
chemin.write_text(original, encoding="utf-8")
|
||||
|
||||
|
||||
def face_publiee() -> None:
|
||||
"""Chaque locataire a publie sa face reseau a jour — et une face publiee alteree se voit."""
|
||||
couples = C.couples_hebergeur()
|
||||
if not couples:
|
||||
print("SAUTE aucun site frere : aucune face publiee a confronter")
|
||||
return
|
||||
for _site, loc in sorted({(s.nom, l) for s, l in couples}, key=lambda x: x[1].nom):
|
||||
e = C.verifier_face_publiee(loc)
|
||||
verifier(not e, f"{loc.nom} a publie sa face reseau a jour" + (f" ({e[0]})" if e else ""))
|
||||
loc = couples[0][1]
|
||||
chemin = loc.depot / C.NOM_FICHIER_FACE_RESEAU
|
||||
original = chemin.read_text(encoding="utf-8")
|
||||
try:
|
||||
chemin.write_text(original.replace("cle_publique: ssh-ed25519", "cle_publique: ssh-rsa", 1),
|
||||
encoding="utf-8")
|
||||
verifier(bool(C.verifier_face_publiee(loc)), "une face publiee alteree est vue perimee")
|
||||
finally:
|
||||
chemin.write_text(original, encoding="utf-8")
|
||||
|
||||
|
||||
def proxmox_lit_la_face() -> None:
|
||||
"""Le devis Proxmox lit la face publiee : un verdict bascule dans la face retire sa regle."""
|
||||
import devis_proxmox_fw
|
||||
import devis_reseau
|
||||
import yaml as _yaml
|
||||
couples = C.couples_hebergeur()
|
||||
if not couples:
|
||||
print("SAUTE aucun site frere : aucun devis Proxmox a confronter")
|
||||
return
|
||||
site, loc = couples[0]
|
||||
face = loc.face_publiee() or {}
|
||||
cible = next(((g, k) for g, v in (face.get("conditions") or {}).items() for k, ok in v.items()
|
||||
if ok and k.startswith("ingress|tcp|")), None)
|
||||
if cible is None:
|
||||
print("SAUTE aucune condition vraie a basculer")
|
||||
return
|
||||
g, cle = cible
|
||||
port = cle.split("|")[2]
|
||||
|
||||
def regles_du_port() -> int:
|
||||
with C._monter(site):
|
||||
bloc = next(b for b in devis_proxmox_fw.construire(devis_reseau.decouvrir_du_site())["blocs"]
|
||||
if b["tenant"] == loc.nom)
|
||||
return sum(1 for gr in bloc["groupes"] if gr["role"] == g
|
||||
for r in gr["regles"] if str(r.get("dport")) == port)
|
||||
|
||||
chemin = loc.depot / C.NOM_FICHIER_FACE_RESEAU
|
||||
original = chemin.read_text(encoding="utf-8")
|
||||
avant = regles_du_port()
|
||||
try:
|
||||
f = _yaml.safe_load(original)
|
||||
f["conditions"][g][cle] = False
|
||||
chemin.write_text(_yaml.safe_dump(f, allow_unicode=True, sort_keys=False), encoding="utf-8")
|
||||
apres = regles_du_port()
|
||||
finally:
|
||||
chemin.write_text(original, encoding="utf-8")
|
||||
verifier(avant > 0 and apres == 0,
|
||||
f"le devis Proxmox lit la face publiee : {g} {port}/tcp, {avant} regle(s), 0 une fois le verdict bascule")
|
||||
|
||||
|
||||
def frontiere_lit_la_face() -> None:
|
||||
"""La frontiere lit la face publiee : un verdict bascule dans la face retire sa regle WAN."""
|
||||
import yaml as _yaml
|
||||
couples = C.couples_hebergeur()
|
||||
if not couples:
|
||||
print("SAUTE aucun site frere : aucune frontiere a confronter")
|
||||
return
|
||||
site, loc = couples[0]
|
||||
face = loc.face_publiee() or {}
|
||||
cible = next(((g, k) for g, v in (face.get("conditions") or {}).items() for k, ok in v.items()
|
||||
if ok and k.startswith("ingress|tcp|") and g == "serveur_postfix"), None)
|
||||
if cible is None:
|
||||
print("SAUTE aucune soumission Postfix active a basculer")
|
||||
return
|
||||
g, cle = cible
|
||||
port = cle.split("|")[2]
|
||||
|
||||
def regles_wan() -> int:
|
||||
import devis_opnsense
|
||||
devis_opnsense._FACES.clear()
|
||||
_e, devis = C._devis_frontiere(site, loc)
|
||||
return sum(1 for r in devis.get("regles") or []
|
||||
if r.get("tenant") == loc.nom and r.get("role") == g and port in (r.get("ports") or [])
|
||||
and r.get("interface") == devis.get("if_wan"))
|
||||
|
||||
chemin = loc.depot / C.NOM_FICHIER_FACE_RESEAU
|
||||
original = chemin.read_text(encoding="utf-8")
|
||||
avant = regles_wan()
|
||||
try:
|
||||
f = _yaml.safe_load(original)
|
||||
f["conditions"][g][cle] = False
|
||||
chemin.write_text(_yaml.safe_dump(f, allow_unicode=True, sort_keys=False), encoding="utf-8")
|
||||
apres = regles_wan()
|
||||
finally:
|
||||
chemin.write_text(original, encoding="utf-8")
|
||||
import devis_opnsense
|
||||
devis_opnsense._FACES.clear()
|
||||
verifier(avant > 0 and apres == 0,
|
||||
f"la frontiere lit la face publiee : {g} {port}/tcp sur le WAN, {avant} regle(s), 0 une fois le verdict bascule")
|
||||
|
||||
|
||||
def decouverte_lit_la_face() -> None:
|
||||
"""La decouverte du site lit l'index que le locataire publie, pas sa nomenclature."""
|
||||
import devis_reseau
|
||||
import yaml as _yaml
|
||||
couples = C.couples_hebergeur()
|
||||
if not couples:
|
||||
print("SAUTE aucun site frere : aucune decouverte a confronter")
|
||||
return
|
||||
site, loc = couples[0]
|
||||
chemin = loc.depot / C.NOM_FICHIER_FACE_RESEAU
|
||||
original = chemin.read_text(encoding="utf-8")
|
||||
|
||||
def index_decouvert():
|
||||
with C._monter(site):
|
||||
return next((n["index"] for nom, _p, n in devis_reseau.decouvrir_du_site() if nom == loc.nom), None)
|
||||
|
||||
avant = index_decouvert()
|
||||
try:
|
||||
f = _yaml.safe_load(original)
|
||||
f["nomenclature"]["index"] = 99
|
||||
chemin.write_text(_yaml.safe_dump(f, allow_unicode=True, sort_keys=False), encoding="utf-8")
|
||||
apres = index_decouvert()
|
||||
finally:
|
||||
chemin.write_text(original, encoding="utf-8")
|
||||
verifier(avant == loc.index and apres == 99,
|
||||
f"la decouverte du site lit l'index publie : {avant}, puis 99 une fois la face alteree")
|
||||
|
||||
|
||||
def pools_complets() -> None:
|
||||
"""Chaque pool porte toutes les VM que le locataire publie (regression du 2026-10-05 : la
|
||||
face ne publiait pas `fonctions`, et le devis des pools s'est retrouve vide, sans erreur)."""
|
||||
import devis_proxmox_pools
|
||||
import devis_reseau
|
||||
couples = C.couples_hebergeur()
|
||||
if not couples:
|
||||
print("SAUTE aucun site frere : aucun pool a confronter")
|
||||
return
|
||||
site = couples[0][0]
|
||||
with C._monter(site):
|
||||
pools = {b["tenant"]: b for b in devis_proxmox_pools.construire(devis_reseau.decouvrir_du_site())["pools"]}
|
||||
for _s, loc in couples:
|
||||
b = pools.get(loc.nom) or {}
|
||||
attendu = len((loc.face_publiee() or {}).get("machines") or {})
|
||||
verifier(len(b.get("membres") or []) == attendu and not b.get("sans_vmid"),
|
||||
f"pool {loc.nom} : {len(b.get('membres') or [])} VM sur {attendu} publiees, aucune sans VMID")
|
||||
|
||||
|
||||
def pools_et_tunnels_lisent_la_face() -> None:
|
||||
"""Les pools et les tunnels d'administration lisent la face publiee : l'alterer les change."""
|
||||
import devis_proxmox_pools
|
||||
import devis_reseau
|
||||
import vpn_admin
|
||||
import yaml as _yaml
|
||||
couples = C.couples_hebergeur()
|
||||
if not couples:
|
||||
print("SAUTE aucun site frere : ni pool ni tunnel a confronter")
|
||||
return
|
||||
site = couples[0][0]
|
||||
loc = next((l for _s, l in couples if (l.face_publiee() or {}).get("administration", {}).get("pairs")),
|
||||
couples[0][1])
|
||||
chemin = loc.depot / C.NOM_FICHIER_FACE_RESEAU
|
||||
original = chemin.read_text(encoding="utf-8")
|
||||
|
||||
def releve():
|
||||
with C._monter(site):
|
||||
pool = next(b for b in devis_proxmox_pools.construire(devis_reseau.decouvrir_du_site())["pools"]
|
||||
if b["tenant"] == loc.nom)
|
||||
tun = next((x for x in vpn_admin.tunnels(True) if x.get("portee") == loc.nom), {})
|
||||
return sorted(m["vmid"] for m in pool["membres"]), sorted(tun.get("pairs") or {})
|
||||
|
||||
vmids, pairs = releve()
|
||||
try:
|
||||
f = _yaml.safe_load(original)
|
||||
premier = sorted(f["machines"])[0]
|
||||
f["machines"][premier]["materialisation"]["proxmox_vmid"] = 999999999
|
||||
f["administration"]["pairs"] = {}
|
||||
chemin.write_text(_yaml.safe_dump(f, allow_unicode=True, sort_keys=False), encoding="utf-8")
|
||||
vmids2, pairs2 = releve()
|
||||
finally:
|
||||
chemin.write_text(original, encoding="utf-8")
|
||||
verifier(999999999 in vmids2 and 999999999 not in vmids, "le devis des pools lit la face publiee (VMID altere repris)")
|
||||
verifier(bool(pairs) and not pairs2,
|
||||
f"les tunnels d'administration lisent la face publiee ({len(pairs)} pair(s), puis 0)")
|
||||
|
||||
|
||||
def clonage_publie() -> None:
|
||||
"""La face publie, machine par machine, les parametres de clonage de `parametres-proxmox`
|
||||
— et une valeur alteree se voit."""
|
||||
import copy
|
||||
couples = C.couples_hebergeur()
|
||||
if not couples:
|
||||
print("SAUTE aucun site frere : aucun parametre de clonage a confronter")
|
||||
return
|
||||
for _s, loc in couples:
|
||||
e = C.verifier_clonage(loc)
|
||||
verifier(not e, f"{loc.nom} : la face porte les parametres de clonage de chaque machine"
|
||||
+ (f" ({e[0]})" if e else ""))
|
||||
loc = couples[0][1]
|
||||
f = copy.deepcopy(loc.face_publiee())
|
||||
h = sorted(f["machines"])[0]
|
||||
f["machines"][h]["clonage"]["SETOPS_MEMOIRE"] = "1"
|
||||
verifier(bool(C.verifier_clonage(loc, f)), f"un parametre de clonage altere ({h}, memoire) se voit")
|
||||
|
||||
|
||||
def materialisation_par_la_face() -> None:
|
||||
"""Creer, raser et placer par la face visent les memes intrants que par l'instance montee
|
||||
— et une face alteree (VMID, etat, pont, placement) se voit, chacune a part."""
|
||||
import copy
|
||||
couples = C.couples_hebergeur()
|
||||
if not couples:
|
||||
print("SAUTE aucun site frere : aucune materialisation a confronter")
|
||||
return
|
||||
for _s, loc in couples:
|
||||
e = C.verifier_materialisation(loc)
|
||||
verifier(not e, f"{loc.nom} : creer, raser et placer par la face = par l'instance"
|
||||
+ (f" ({e[0]})" if e else ""))
|
||||
loc = couples[0][1]
|
||||
h = sorted(loc.face_publiee()["machines"])[0]
|
||||
def altere(fn):
|
||||
f = copy.deepcopy(loc.face_publiee())
|
||||
fn(f)
|
||||
return C.verifier_materialisation(loc, f)
|
||||
e = altere(lambda f: f["machines"][h]["materialisation"].__setitem__("proxmox_vmid", 999999999))
|
||||
verifier(any("raser" in x for x in e), f"un VMID altere ({h}) se voit cote raser")
|
||||
e = altere(lambda f: f["machines"][h].__setitem__("etat", "retire"))
|
||||
verifier(any("creer" in x for x in e), f"un etat altere ({h}) se voit cote creation")
|
||||
e = altere(lambda f: f["machines"][h]["materialisation"].__setitem__("proxmox_pont", "zzpont"))
|
||||
verifier(any("ponts" in x for x in e), f"un pont altere ({h}) se voit cote placement")
|
||||
e = altere(lambda f: f.__setitem__("placement", {**(f.get("placement") or {}), "proxmox_noeud": "zz"}))
|
||||
verifier(any("placement" in x for x in e), "une valeur de placement alteree se voit")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
d = Path(tmp)
|
||||
moteur = fabriquer(d)
|
||||
classes(d)
|
||||
contexte_actif(d, moteur)
|
||||
vrais_depots()
|
||||
monter_restaure()
|
||||
fiches()
|
||||
faces()
|
||||
flux()
|
||||
frontiere()
|
||||
entrees_publiques()
|
||||
administration()
|
||||
sorties()
|
||||
fiche_deposee()
|
||||
face_publiee()
|
||||
proxmox_lit_la_face()
|
||||
frontiere_lit_la_face()
|
||||
decouverte_lit_la_face()
|
||||
pools_complets()
|
||||
pools_et_tunnels_lisent_la_face()
|
||||
clonage_publie()
|
||||
materialisation_par_la_face()
|
||||
if ECHECS:
|
||||
print(f"\n{len(ECHECS)} echec(s).")
|
||||
return 1
|
||||
print("\nLe contexte se nomme, se reconnait, et refuse ce qui ne designe rien.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -102,6 +102,37 @@ def test_le_reseau_mesure_est_celui_ou_les_VM_atterrissent(tmp=None):
|
|||
"le devis ne doit plus presenter le pont des clones manuels"
|
||||
|
||||
|
||||
def test_le_devis_nomme_le_locataire_designe():
|
||||
"""Par `--locataire`, aucun fichier source d'ou tirer le nom : le devis annoncait
|
||||
« tenant « ? » » au pre-vol de M4 (2026-10-07). Il nomme celui qu'on lui a designe."""
|
||||
import contextlib
|
||||
import io
|
||||
assert dp.nom_du_tenant(None, "OPS-Exemple") == "OPS-Exemple"
|
||||
source = Path("/d/OPS-Monte/inventories/principal/group_vars/proxmox.yml")
|
||||
assert dp.nom_du_tenant(source) == "OPS-Monte", "le chemin monte nomme toujours son tenant"
|
||||
sortie = io.StringIO()
|
||||
with contextlib.redirect_stdout(sortie):
|
||||
dp.afficher([], dp.nom_du_tenant(None, "OPS-Exemple"), Path("/d/SITE-Exemple"))
|
||||
entete = sortie.getvalue().splitlines()[0]
|
||||
assert "« OPS-Exemple »" in entete and "?" not in entete, f"en-tete : {entete!r}"
|
||||
|
||||
|
||||
def test_le_devis_verifie_le_gabarit_et_le_stockage_que_le_clonage_utilise():
|
||||
"""Le site decide du gabarit et de son stockage ; `cloner-vm` les passe en `-e`. Le devis
|
||||
lisait le tenant : il validait chez Technolibre un 99998 que plus rien ne clone, et
|
||||
refusait chez Chezlepro un gabarit « None » (pre-vol de sa reconstruction, 2026-10-07)."""
|
||||
site = {"vmid": 9006, "nom": "modeleSetOPS-minimal", "noeud": "vishnu", "stockage": "CephNVMe"}
|
||||
perime = {**PLACEMENT, "proxmox_clone_vmid_modele": 99998, "proxmox_clone_stockage": "TrueNAS"}
|
||||
vu = dp.ce_que_le_clonage_utilise(perime, site)
|
||||
assert vu["proxmox_clone_vmid_modele"] == 9006 and vu["proxmox_clone_stockage"] == "CephNVMe", vu
|
||||
sans = {k: v for k, v in perime.items() if k != "proxmox_clone_vmid_modele"}
|
||||
assert dp.ce_que_le_clonage_utilise(sans, site)["proxmox_clone_vmid_modele"] == 9006, \
|
||||
"un tenant qui suit la regle (aucun gabarit declare) recoit celui du site"
|
||||
assert dp.ce_que_le_clonage_utilise(perime, {}) == perime, \
|
||||
"sans gabarit declare par le site, le tenant reprend la main, tel quel"
|
||||
assert perime["proxmox_clone_vmid_modele"] == 99998, "le placement du tenant n'est pas modifie en place"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
|
||||
for t in tests:
|
||||
|
|
|
|||
112
scripts/tests/test_genome_colis.py
Normal file
112
scripts/tests/test_genome_colis.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
#!/usr/bin/env python3
|
||||
"""`genome_colis.py` : le colis porte la branche ET les releases jusqu'à la forge du site.
|
||||
|
||||
POURQUOI (2026-10-07). Le colis ne portait que `main`. Les étiquettes de release
|
||||
n'arrivaient donc jamais sur la forge du site, qui fait autorité (D-81) : `v2026.08.21` et
|
||||
`v2026.10.07` y manquaient, et il a fallu les pousser à la main.
|
||||
|
||||
Sur de vrais dépôts git, dans un répertoire jetable (aucun réseau) :
|
||||
- un poste, une forge nue, un runner cloné depuis la forge ;
|
||||
- le poste fait un commit et étiquette une release ; `emballer`, puis `recevoir` sur le
|
||||
runner, avec la forge pour `pushurl` ;
|
||||
- la forge porte la nouvelle tête ET l'étiquette ; un dépôt sans étiquette passe aussi ;
|
||||
- une étiquette déplacée sur le poste est REFUSÉE : une release ne bouge pas.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
RACINE = Path(__file__).resolve().parents[2]
|
||||
COLIS = RACINE / "scripts" / "genome_colis.py"
|
||||
ENV = {**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t",
|
||||
"GIT_COMMITTER_EMAIL": "t@t", "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_NOSYSTEM": "1"}
|
||||
|
||||
ECHECS: list[str] = []
|
||||
|
||||
|
||||
def verifier(cond: bool, msg: str) -> None:
|
||||
print(("OK " if cond else "ECHEC ") + msg)
|
||||
if not cond:
|
||||
ECHECS.append(msg)
|
||||
|
||||
|
||||
def git(*args: str, cwd: Path | None = None) -> str:
|
||||
r = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, env=ENV)
|
||||
if r.returncode != 0:
|
||||
raise RuntimeError(f"git {' '.join(args)} : {r.stderr.strip()}")
|
||||
return r.stdout.strip()
|
||||
|
||||
|
||||
def colis(*args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run([sys.executable, str(COLIS), *args], capture_output=True, text=True, env=ENV)
|
||||
|
||||
|
||||
def monter(d: Path) -> tuple[Path, Path, Path]:
|
||||
forge, poste, runner = d / "forge.git", d / "poste", d / "runner"
|
||||
git("init", "-q", "--bare", "-b", "main", str(forge))
|
||||
git("init", "-q", "-b", "main", str(poste))
|
||||
(poste / "a").write_text("1")
|
||||
git("add", "a", cwd=poste)
|
||||
git("commit", "-q", "-m", "un", cwd=poste)
|
||||
git("push", "-q", str(forge), "main", cwd=poste)
|
||||
git("clone", "-q", str(forge), str(runner))
|
||||
return forge, poste, runner
|
||||
|
||||
|
||||
def porter(poste: Path, runner: Path, forge: Path, d: Path) -> subprocess.CompletedProcess:
|
||||
paquet = d / "x.bundle"
|
||||
e = colis("emballer", str(poste), str(paquet))
|
||||
if e.returncode != 0:
|
||||
return e
|
||||
return colis("recevoir", str(runner), str(paquet), str(forge))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
d = Path(d)
|
||||
forge, poste, runner = monter(d)
|
||||
|
||||
# Un depot sans etiquette : rien ne change pour lui.
|
||||
(poste / "a").write_text("2")
|
||||
git("commit", "-q", "-am", "deux", cwd=poste)
|
||||
r = porter(poste, runner, forge, d)
|
||||
verifier(r.returncode == 0 and git("rev-parse", "main", cwd=forge) == git("rev-parse", "HEAD", cwd=poste),
|
||||
f"sans etiquette : la forge porte la tete du poste ({r.stderr.strip()[-160:]})")
|
||||
|
||||
# Une release annotee.
|
||||
(poste / "a").write_text("3")
|
||||
git("commit", "-q", "-am", "trois", cwd=poste)
|
||||
git("tag", "-a", "v1", "-m", "release v1", cwd=poste)
|
||||
r = porter(poste, runner, forge, d)
|
||||
recu = json.loads(r.stdout) if r.returncode == 0 else {}
|
||||
verifier(r.returncode == 0 and "v1" in git("tag", "-l", cwd=forge).split(),
|
||||
f"la release voyage jusqu'a la forge ({r.stderr.strip()[-160:]})")
|
||||
verifier(git("rev-parse", "v1", cwd=forge) == git("rev-parse", "v1", cwd=poste),
|
||||
"la forge porte le MEME objet d'etiquette que le poste (annotee, pas recreee)")
|
||||
verifier(recu.get("etiquettes") == ["v1"], f"le runner dit les etiquettes qu'il porte ({recu.get('etiquettes')})")
|
||||
e = json.loads(colis("emballer", str(poste), str(d / "y.bundle")).stdout)
|
||||
verifier(e.get("etiquettes") == ["v1"], "l'emballage dit les etiquettes du poste")
|
||||
|
||||
# Une release deplacee sur le poste : refusee, la forge garde l'ancienne.
|
||||
ancienne = git("rev-parse", "v1", cwd=forge)
|
||||
(poste / "a").write_text("4")
|
||||
git("commit", "-q", "-am", "quatre", cwd=poste)
|
||||
git("tag", "-f", "-a", "v1", "-m", "v1 deplacee", cwd=poste)
|
||||
r = porter(poste, runner, forge, d)
|
||||
verifier(r.returncode != 0 and git("rev-parse", "v1", cwd=forge) == ancienne,
|
||||
f"une etiquette deplacee est refusee, la forge garde la release ({r.stderr.strip()[-120:]})")
|
||||
|
||||
if ECHECS:
|
||||
print(f"\n{len(ECHECS)} echec(s).")
|
||||
return 1
|
||||
print("\nLe colis porte la branche et les releases ; une release ne bouge pas.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -55,3 +55,33 @@ print(f"\n>>> instance montee : {NOM} | collision fabriquee sur {VMID_1} ({NOM
|
|||
print(f">>> code de sortie : {code} (2 = refus attendu)")
|
||||
assert code == 2, "LE VERROU N'A PAS TENU"
|
||||
print(">>> le verrou tient : aucune VM n'aurait ete touchee")
|
||||
|
||||
# L'EN-TETE DIT D'OU VIENT LA LISTE (2026-10-07). Sur le runner du site, `--locataire`
|
||||
# annoncait « Ecosysteme monte » — rien n'y est monte, le locataire est NOMME et sa face fait
|
||||
# foi. Lu au pre-vol de M4, avant de raser : un devis qui se trompe de provenance fait douter
|
||||
# de la liste. Le cluster rend ici exactement les machines publiees : rien n'est refuse, et
|
||||
# sans `--confirmer` rien n'est fait — seul l'en-tete est juge, sur la sortie reelle.
|
||||
import contextlib # noqa: E402
|
||||
import io # noqa: E402
|
||||
|
||||
try:
|
||||
_, COUPLES_FACE = raser.couples_publies(BASE.name)
|
||||
except (SystemExit, Exception) as e: # noqa: BLE001 — une instance sans face publiee
|
||||
COUPLES_FACE = []
|
||||
print(f">>> SAUTE l'en-tete du chemin nomme : {e}")
|
||||
if COUPLES_FACE:
|
||||
class ClusterConforme(FauxCluster):
|
||||
def __call__(self, chemin, methode="GET", corps=None):
|
||||
return [{"type": "qemu", "vmid": v, "name": n, "node": "asgard"}
|
||||
for n, v in COUPLES_FACE]
|
||||
|
||||
raser.Cluster = ClusterConforme
|
||||
for args, attendu, banni in ((["--locataire", BASE.name], "Locataire nommé", "monté"),
|
||||
([], "Écosystème monté", "nommé")):
|
||||
sortie = io.StringIO()
|
||||
with contextlib.redirect_stdout(sortie):
|
||||
code = raser.main(args)
|
||||
entete = sortie.getvalue().splitlines()[0]
|
||||
assert code == 0, f"code {code} sur un cluster conforme ({' '.join(args) or 'instance'})"
|
||||
assert attendu in entete and banni not in entete, f"EN-TETE TROMPEUR : {entete!r}"
|
||||
print(f">>> {' '.join(args) or 'instance montee'} : {entete}")
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ import yaml
|
|||
|
||||
RACINE = Path(__file__).resolve().parents[2]
|
||||
GABARIT = RACINE / "roles/client_backup/templates/restaurer.sh.j2"
|
||||
SAUVEGARDE = RACINE / "roles/client_backup/templates/sauvegarder.sh.j2"
|
||||
COMPARATEUR = RACINE / "roles/client_backup/files/setops-temoins.py"
|
||||
|
||||
ECHECS: list[str] = []
|
||||
|
||||
|
|
@ -120,9 +122,17 @@ def outil() -> None:
|
|||
dump = snap / str(staging).lstrip("/") / "postgresql" / "toutes-bases.sql"
|
||||
dump.parent.mkdir(parents=True)
|
||||
dump.write_text(DUMP)
|
||||
# Le troisieme, etiquete au rasage, est PLUS ANCIEN que le candidat : les temoins
|
||||
# doivent le choisir quand meme (c'est l'etiquette qui designe, pas l'heure).
|
||||
rasage = fixt / "rasage03" / str(d / "vmail").lstrip("/") / "boite" / "cur"
|
||||
rasage.mkdir(parents=True)
|
||||
(rasage / "1700.M1.h:2,S").write_text("le courriel d'avant")
|
||||
snaps = [
|
||||
{"short_id": "avant01", "id": "avant01" + "0" * 56, "time": "2026-09-29T23:50:50.123456789-04:00"},
|
||||
{"short_id": "apres02", "id": "apres02" + "0" * 56, "time": "2099-01-01T00:00:00Z"},
|
||||
{"short_id": "rasage03", "id": "rasage03" + "0" * 56, "time": "2026-09-29T10:00:00-04:00",
|
||||
"tags": ["setops", "avant-raser"]},
|
||||
{"short_id": "avant01", "id": "avant01" + "0" * 56, "time": "2026-09-29T23:50:50.123456789-04:00",
|
||||
"tags": ["setops"]},
|
||||
{"short_id": "apres02", "id": "apres02" + "0" * 56, "time": "2099-01-01T00:00:00Z", "tags": ["setops"]},
|
||||
]
|
||||
(d / "snapshots.json").write_text(json.dumps(snaps))
|
||||
|
||||
|
|
@ -134,7 +144,12 @@ if [[ -n "${{SETOPS_TEST_DEPOT_ABSENT:-}}" ]]; then exit 10; fi
|
|||
[[ "$1" == --retry-lock && -n "$2" ]] || {{ echo "restic appele sans --retry-lock : $*" >&2; exit 99; }}
|
||||
shift 2
|
||||
case "$1" in
|
||||
snapshots) cat "{d}/snapshots.json" ;;
|
||||
snapshots)
|
||||
etiquette=""
|
||||
while (( $# )); do [[ "$1" == --tag ]] && etiquette="$2"; shift; done
|
||||
python3 -c 'import json, sys; e = sys.argv[2]
|
||||
print(json.dumps([x for x in json.load(open(sys.argv[1])) if not e or e in x.get("tags", [])]))' \
|
||||
"{d}/snapshots.json" "$etiquette" ;;
|
||||
restore)
|
||||
id="$2"; shift 2; cible=""; inc=()
|
||||
while (( $# )); do case "$1" in --target) cible="$2"; shift 2;; --include) inc+=("$2"); shift 2;; *) shift;; esac; done
|
||||
|
|
@ -159,23 +174,31 @@ if (( ! avec_c )); then cat > "{log}/section.sql"; fi
|
|||
executable(bin_ / "pg_dump", '#!/bin/bash\necho dump\n')
|
||||
executable(bin_ / "dropdb", f'#!/bin/bash\necho "dropdb $*" >> "{log}/pg"\n')
|
||||
|
||||
rendu = jinja2.Environment(undefined=jinja2.StrictUndefined).from_string(
|
||||
GABARIT.read_text()).render(
|
||||
client_backup_repo="sftp:restic@depot:hote",
|
||||
inventory_hostname="hote",
|
||||
client_backup_restauration_marques=str(d / "marques"),
|
||||
client_backup_restauration_mise_de_cote=str(d / "mis-de-cote"),
|
||||
client_backup_staging=str(staging),
|
||||
client_backup_jobs=[{"nom": "courriel"}, {"nom": "postgresql"}],
|
||||
client_backup_attente_verrou="30m",
|
||||
).replace("/etc/ssh/ssh_host_ed25519_key.pub", str(cle)) \
|
||||
.replace("/etc/setops/restic.pass", str(d / "restic.pass"))
|
||||
script = d / "setops-restaurer"
|
||||
executable(script, rendu)
|
||||
executable(bin_ / "setops-temoins", f'#!/bin/bash\nexec python3 "{COMPARATEUR}" "$@"\n')
|
||||
|
||||
def rendre(nom: str, jeux: list[dict]) -> Path:
|
||||
rendu = jinja2.Environment(undefined=jinja2.StrictUndefined).from_string(
|
||||
GABARIT.read_text()).render(
|
||||
client_backup_repo="sftp:restic@depot:hote",
|
||||
inventory_hostname="hote",
|
||||
client_backup_restauration_marques=str(d / "marques"),
|
||||
client_backup_restauration_mise_de_cote=str(d / "mis-de-cote"),
|
||||
client_backup_staging=str(staging),
|
||||
client_backup_jobs=jeux,
|
||||
client_backup_attente_verrou="30m",
|
||||
client_backup_etiquette_avant_raser="avant-raser",
|
||||
).replace("/etc/ssh/ssh_host_ed25519_key.pub", str(cle)) \
|
||||
.replace("/etc/setops/restic.pass", str(d / "restic.pass")) \
|
||||
.replace("/usr/local/sbin/setops-temoins", str(bin_ / "setops-temoins"))
|
||||
executable(d / nom, rendu)
|
||||
return d / nom
|
||||
|
||||
vmail_jeu = {"nom": "courriel", "chemins": [str(d / "vmail")]}
|
||||
script = rendre("setops-restaurer", [vmail_jeu, {"nom": "postgresql", "chemins": [str(staging / "postgresql")]}])
|
||||
env = {**os.environ, "PATH": f"{bin_}:{os.environ['PATH']}"}
|
||||
|
||||
def lancer(*args: str, **extra: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(["bash", str(script), *args], env={**env, **extra},
|
||||
def lancer(*args: str, outil: Path = script, **extra: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(["bash", str(outil), *args], env={**env, **extra},
|
||||
capture_output=True, text=True)
|
||||
|
||||
r = lancer("choisir")
|
||||
|
|
@ -198,6 +221,33 @@ if (( ! avec_c )); then cat > "{log}/section.sql"; fi
|
|||
f"--remplacer remet l'etat d'avant, a l'identique ({r.stdout.strip() or r.stderr.strip()})")
|
||||
verifier(any((d / "mis-de-cote").rglob("x")), "l'etat ecrase a ete mis de cote")
|
||||
|
||||
# LA COPIE A FROID (2026-10-07) : l'instantane porte la copie posee dans la
|
||||
# preparation ; `--depuis` la remet a sa place vivante, qui porte un autre nom.
|
||||
copie = fixt / "avant01" / str(staging).lstrip("/") / "prometheus" / "metrics2" / ("01" + "B" * 24)
|
||||
copie.mkdir(parents=True)
|
||||
(copie / "meta.json").write_text("{}")
|
||||
vivante = d / "prometheus-vivant"
|
||||
(vivante / "wal").mkdir(parents=True)
|
||||
(vivante / "wal" / "00000000").write_text("neuf")
|
||||
r = lancer("fichiers", "--depuis", str(staging / "prometheus" / "metrics2"), "--remplacer", str(vivante))
|
||||
verifier(r.returncode == 0 and (vivante / ("01" + "B" * 24) / "meta.json").exists()
|
||||
and not (vivante / "wal").exists(),
|
||||
f"--depuis remet la copie a froid a sa place vivante ({r.stdout.strip() or r.stderr.strip()})")
|
||||
r = lancer("fichiers", "--depuis", str(staging), str(d / "a"), str(d / "b"))
|
||||
verifier(r.returncode != 0, "--depuis refuse plusieurs destinations")
|
||||
|
||||
# LES TEMOINS (2026-10-07) : l'instantane etiquete au rasage, pas le candidat.
|
||||
seul_courriel = rendre("setops-restaurer-courriel", [vmail_jeu])
|
||||
r = lancer("temoins", outil=seul_courriel)
|
||||
verifier(r.returncode == 1 and "instantane rasage03" in r.stdout and "perdu : boite/msg/1700.M1.h" in r.stdout,
|
||||
f"les temoins comparent a l'instantane etiquete au rasage, et voient le courriel perdu "
|
||||
f"(code {r.returncode}) {r.stdout.strip()[-200:] or r.stderr.strip()[-200:]}")
|
||||
r = lancer("temoins", "--candidat", outil=seul_courriel)
|
||||
verifier(r.returncode == 0 and "instantane avant01" in r.stdout,
|
||||
f"--candidat compare au candidat, que la restauration a remis a l'identique ({r.stdout.strip()[-160:]})")
|
||||
r = lancer("temoins", outil=rendre("setops-restaurer-vide", []))
|
||||
verifier(r.returncode == 4 and "SANS OBJET" in r.stdout, "un noeud sans etat : rien a comparer (code 4)")
|
||||
|
||||
r = lancer("base", "nextcloud")
|
||||
section = (log / "section.sql").read_text() if (log / "section.sql").exists() else ""
|
||||
verifier(r.returncode == 0 and "oc_users" in section, f"la section nextcloud est rejouee ({r.stderr.strip()[:120]})")
|
||||
|
|
@ -210,11 +260,19 @@ if (( ! avec_c )); then cat > "{log}/section.sql"; fi
|
|||
r = lancer("base", "inexistante")
|
||||
verifier(r.returncode == 0 and "ABSENTE" in r.stdout, "une base absente de l'instantane est dite, pas inventee")
|
||||
|
||||
for jeu in ("courriel", "postgresql"):
|
||||
lancer("acter", jeu, "restaure", "avant01")
|
||||
lancer("acter", "courriel", "restaure", "chasse09")
|
||||
lancer("acter", "postgresql", "restaure", "avant01")
|
||||
r = lancer("garde")
|
||||
verifier(r.returncode == 0, "la sauvegarde reprend une fois chaque jeu acte")
|
||||
|
||||
# LE BILAN DIT CE QUI A ETE RESTAURE, ET S'IL EST ENCORE AU DEPOT (2026-10-07).
|
||||
r = lancer("etat")
|
||||
verifier("jeu courriel : etat=restaure instantane=chasse09" in r.stdout and "(RETIRE du depot)" in r.stdout,
|
||||
"un instantane restaure puis chasse du depot est dit RETIRE")
|
||||
verifier("instantane=avant01" in r.stdout and "(au depot)" in r.stdout, "un instantane encore la est dit au depot")
|
||||
verifier("avant-raser: rasage03" in r.stdout and "lancee maintenant" in r.stdout,
|
||||
"le bilan nomme l'instantane d'avant rasage, et dit que le candidat est calcule maintenant")
|
||||
|
||||
for f in (d / "marques").iterdir():
|
||||
f.unlink()
|
||||
r = lancer("garde", SETOPS_TEST_DEPOT_ABSENT="1")
|
||||
|
|
@ -224,9 +282,147 @@ if (( ! avec_c )); then cat > "{log}/section.sql"; fi
|
|||
"depot inexistant = premiere vie : chaque jeu est acte « neuf »")
|
||||
|
||||
|
||||
# --- 3. Le depot d'avant rasage (2026-10-07) ---------------------------------------------
|
||||
|
||||
def sauvegarde() -> None:
|
||||
"""`--avant-raser` etiquette le depot ; l'etiquette passe du precedent au nouveau ; la
|
||||
retention la garde toujours. A M4, le premier depot d'apres reconstruction avait chasse
|
||||
l'instantane d'avant par `--keep-daily` : plus rien a quoi comparer l'etat remis."""
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
d = Path(d)
|
||||
bin_ = d / "bin"
|
||||
bin_.mkdir()
|
||||
(d / "vmail").mkdir()
|
||||
journal = d / "restic.log"
|
||||
# Deux instantanes etiquetes : l'ancien rasage, et celui qui vient d'etre depose.
|
||||
etiquetes = [{"id": "ancien" + "0" * 58, "time": "2026-10-01T10:00:00-04:00"},
|
||||
{"id": "nouveau" + "0" * 57, "time": "2026-10-07T11:34:44-04:00"}]
|
||||
(d / "etiquetes.json").write_text(json.dumps(etiquetes))
|
||||
executable(bin_ / "restic", f"""#!/bin/bash
|
||||
[[ "$1" == --retry-lock ]] && shift 2
|
||||
echo "$*" >> "{journal}"
|
||||
if [[ "$1" == snapshots && "$*" == *--json* ]]; then cat "{d}/etiquetes.json"; fi
|
||||
exit 0
|
||||
""")
|
||||
executable(bin_ / "setops-restaurer", "#!/bin/bash\nexit 0\n")
|
||||
rendu = jinja2.Environment(undefined=jinja2.StrictUndefined).from_string(
|
||||
SAUVEGARDE.read_text()).render(
|
||||
client_backup_repo="sftp:restic@depot:hote", inventory_hostname="hote",
|
||||
client_backup_attente_verrou="30m", client_backup_staging=str(d / "staging"),
|
||||
client_backup_jobs=[{"nom": "courriel", "chemins": [str(d / "vmail")]}],
|
||||
client_backup_retention="--keep-daily 7", client_backup_etiquette_avant_raser="avant-raser",
|
||||
).replace("/usr/local/sbin/setops-restaurer", str(bin_ / "setops-restaurer"))
|
||||
script = d / "setops-sauvegarder.sh"
|
||||
executable(script, rendu)
|
||||
env = {**os.environ, "PATH": f"{bin_}:{os.environ['PATH']}"}
|
||||
|
||||
def lancer(*args: str) -> list[str]:
|
||||
journal.unlink(missing_ok=True)
|
||||
r = subprocess.run(["bash", str(script), *args], env=env, capture_output=True, text=True)
|
||||
verifier(r.returncode == 0, f"la sauvegarde {' '.join(args) or 'ordinaire'} aboutit ({r.stderr.strip()[-160:]})")
|
||||
return journal.read_text().splitlines() if journal.exists() else []
|
||||
|
||||
appels = lancer("--avant-raser")
|
||||
backup = next((a for a in appels if a.startswith("backup ")), "")
|
||||
verifier("--tag avant-raser" in backup, f"le depot d'avant rasage est etiquete ({backup})")
|
||||
retrait = [a for a in appels if a.startswith("tag ")]
|
||||
verifier(retrait == ["tag --remove avant-raser " + etiquetes[0]["id"]],
|
||||
f"l'etiquette est retiree au precedent, et a lui seul ({retrait})")
|
||||
verifier(appels.index(backup) < appels.index(retrait[0]) if retrait else False,
|
||||
"l'etiquette n'est retiree qu'APRES le nouveau depot")
|
||||
forget = next((a for a in appels if a.startswith("forget ")), "")
|
||||
verifier("--keep-tag avant-raser" in forget, f"la retention garde l'etiquette ({forget})")
|
||||
|
||||
appels = lancer()
|
||||
backup = next((a for a in appels if a.startswith("backup ")), "")
|
||||
verifier("avant-raser" not in backup and not any(a.startswith("tag ") for a in appels),
|
||||
"un depot ordinaire n'est pas etiquete et ne touche pas l'etiquette")
|
||||
forget = next((a for a in appels if a.startswith("forget ")), "")
|
||||
verifier("--keep-tag avant-raser" in forget,
|
||||
"un depot ordinaire garde lui aussi l'instantane d'avant rasage (c'est lui qui le chassait)")
|
||||
|
||||
|
||||
# --- 4. La copie a froid (2026-10-07) -----------------------------------------------------
|
||||
|
||||
def copie_a_froid() -> None:
|
||||
"""Le service est arrete le temps de copier, et relance QUOI QU'IL ARRIVE ; les blocs
|
||||
immuables sont lies, le reste copie ; un service inactif n'est ni arrete ni relance."""
|
||||
outil = RACINE / "roles/client_backup/files/setops-copie-a-froid.sh"
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
d = Path(d)
|
||||
bin_, journal = d / "bin", d / "systemctl.log"
|
||||
bin_.mkdir()
|
||||
executable(bin_ / "systemctl", f"""#!/bin/bash
|
||||
echo "$*" >> "{journal}"
|
||||
[[ "$1" == is-active ]] && exit "${{SETOPS_TEST_ACTIF:-0}}"
|
||||
exit 0
|
||||
""")
|
||||
# `cp` qui echoue sur le WAL quand on le demande : une copie ratee en plein milieu.
|
||||
executable(bin_ / "cp", '#!/bin/bash\n[[ -n "${SETOPS_TEST_CP_ECHOUE:-}" && "$*" == *wal* ]] && exit 1\nexec /bin/cp "$@"\n')
|
||||
source, dest = d / "metrics2", d / "prep" / "metrics2"
|
||||
bloc = source / ("01" + "C" * 24)
|
||||
bloc.mkdir(parents=True)
|
||||
(bloc / "index").write_text("bloc")
|
||||
(source / "wal").mkdir()
|
||||
(source / "wal" / "00000001").write_text("wal")
|
||||
env = {**os.environ, "PATH": f"{bin_}:{os.environ['PATH']}"}
|
||||
|
||||
def lancer(**extra: str) -> subprocess.CompletedProcess:
|
||||
journal.unlink(missing_ok=True)
|
||||
return subprocess.run(["bash", str(outil), "prometheus", str(source), str(dest), "--blocs-immuables"],
|
||||
env={**env, **extra}, capture_output=True, text=True)
|
||||
|
||||
r = lancer()
|
||||
appels = journal.read_text().split("\n") if journal.exists() else []
|
||||
verifier(r.returncode == 0 and appels[:3] == ["is-active --quiet prometheus", "stop prometheus", "start prometheus"],
|
||||
f"arrete, copie, relance ({appels[:3]}, {r.stderr.strip()[-120:]})")
|
||||
verifier((dest / bloc.name / "index").stat().st_ino == (bloc / "index").stat().st_ino,
|
||||
"un bloc immuable est lie (meme inode), pas recopie")
|
||||
verifier((dest / "wal" / "00000001").stat().st_ino != (source / "wal" / "00000001").stat().st_ino
|
||||
and (dest / "wal" / "00000001").read_text() == "wal",
|
||||
"le WAL est vraiment copie (un autre inode)")
|
||||
verifier("arrete" in r.stdout and " ms" in r.stdout, f"la duree d'arret est dite ({r.stdout.strip()})")
|
||||
|
||||
(dest / "temoin").write_text("copie precedente")
|
||||
r = lancer(SETOPS_TEST_CP_ECHOUE="1")
|
||||
appels = journal.read_text().split("\n") if journal.exists() else []
|
||||
verifier(r.returncode != 0 and "start prometheus" in appels,
|
||||
f"une copie ratee : le service est QUAND MEME relance ({appels})")
|
||||
verifier((dest / "temoin").exists(), "une copie ratee ne remplace pas la copie precedente")
|
||||
|
||||
r = lancer(SETOPS_TEST_ACTIF="3")
|
||||
appels = journal.read_text().split("\n") if journal.exists() else []
|
||||
verifier(r.returncode == 0 and "stop prometheus" not in appels and "start prometheus" not in appels,
|
||||
"un service inactif n'est ni arrete ni relance")
|
||||
|
||||
# LOKI (2026-10-07) : les morceaux sont lies, `chunks/index/` copie. Il loge la base
|
||||
# du compacteur (`delete_requests.gz`), qui se reecrit : liee, la sauvegarde
|
||||
# changerait avec elle.
|
||||
loki, prep = d / "loki", d / "prep" / "donnees"
|
||||
(loki / "chunks" / "fake").mkdir(parents=True)
|
||||
(loki / "chunks" / "fake" / "MWEw").write_text("morceau")
|
||||
(loki / "chunks" / "index" / "delete_requests").mkdir(parents=True)
|
||||
(loki / "chunks" / "index" / "delete_requests" / "delete_requests.gz").write_text("v1")
|
||||
(loki / "wal").mkdir()
|
||||
(loki / "wal" / "00000001").write_text("wal")
|
||||
r = subprocess.run(["bash", str(outil), "loki", str(loki), str(prep), "--lier", "chunks",
|
||||
"--copier", "chunks/index"], env=env, capture_output=True, text=True)
|
||||
verifier(r.returncode == 0 and (prep / "chunks" / "fake" / "MWEw").stat().st_ino
|
||||
== (loki / "chunks" / "fake" / "MWEw").stat().st_ino,
|
||||
f"Loki : un morceau est lie (meme inode) ({r.stderr.strip()[-120:]})")
|
||||
base = loki / "chunks" / "index" / "delete_requests" / "delete_requests.gz"
|
||||
copie = prep / "chunks" / "index" / "delete_requests" / "delete_requests.gz"
|
||||
verifier(copie.exists() and copie.stat().st_ino != base.stat().st_ino,
|
||||
"Loki : chunks/index est vraiment copie (un autre inode)")
|
||||
base.write_text("v2") # le compacteur reecrit sa base sur place
|
||||
verifier(copie.read_text() == "v1", "une reecriture apres la copie ne change pas la sauvegarde")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
couverture()
|
||||
outil()
|
||||
sauvegarde()
|
||||
copie_a_froid()
|
||||
if ECHECS:
|
||||
print(f"\n{len(ECHECS)} echec(s).")
|
||||
return 1
|
||||
|
|
|
|||
354
scripts/tests/test_temoins.py
Normal file
354
scripts/tests/test_temoins.py
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
#!/usr/bin/env python3
|
||||
"""`setops-temoins` : une perte ou une identité changée est un ÉCART ; la vie ordinaire non.
|
||||
|
||||
POURQUOI (2026-10-07). Les témoins d'une reconstruction se relevaient à la main ; après
|
||||
M4, il ne restait même plus d'instantané d'avant à quoi comparer. Le comparateur qui les
|
||||
remplace doit juger comme l'exploitant jugeait : la racine de l'AC, la clé DKIM,
|
||||
l'`instanceid` de Nextcloud et le mot de passe de `sysadmin` ne bougent JAMAIS ; un
|
||||
courriel lu (`new/` -> `cur/`, drapeau `:2,S`) n'est pas un courriel perdu ; une table de
|
||||
sessions qui grossit n'est pas une base perdue.
|
||||
|
||||
Sur des arbres FABRIQUÉS (un « avant » extrait, un « vivant »), chaque règle est éprouvée
|
||||
dans les deux sens : ce qui doit passer passe, ce qui doit échouer échoue.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
RACINE = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(RACINE / "roles/client_backup/files"))
|
||||
import importlib.util # noqa: E402
|
||||
|
||||
_spec = importlib.util.spec_from_file_location("setops_temoins", RACINE / "roles/client_backup/files/setops-temoins.py")
|
||||
T = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(T)
|
||||
|
||||
ECHECS: list[str] = []
|
||||
|
||||
|
||||
def verifier(cond: bool, msg: str) -> None:
|
||||
print(("OK " if cond else "ECHEC ") + msg)
|
||||
if not cond:
|
||||
ECHECS.append(msg)
|
||||
|
||||
|
||||
def ecrire(p: Path, texte: str) -> None:
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(texte)
|
||||
|
||||
|
||||
LDIF = """dn: dc=exemple,dc=internal
|
||||
objectClass: domain
|
||||
dc: exemple
|
||||
entryCSN: 20261007153000.000000Z#000000#000#000000
|
||||
|
||||
dn: uid=sysadmin,ou=people,dc=exemple,dc=internal
|
||||
objectClass: inetOrgPerson
|
||||
uid: sysadmin
|
||||
cn: Sysadmin
|
||||
userPassword:: {mdp}
|
||||
modifyTimestamp: 20261001000000Z
|
||||
|
||||
dn: uid=marie,ou=people,dc=exemple,dc=internal
|
||||
objectClass: inetOrgPerson
|
||||
uid: marie
|
||||
cn: Marie
|
||||
mail: marie@exemple.internal
|
||||
|
||||
"""
|
||||
|
||||
DUMP = r"""CREATE ROLE keycloak;
|
||||
CREATE ROLE postgres;
|
||||
CREATE DATABASE template1 WITH TEMPLATE = template0 ENCODING = 'UTF8';
|
||||
CREATE DATABASE keycloak WITH TEMPLATE = template0 ENCODING = 'UTF8';
|
||||
CREATE DATABASE icingadb WITH TEMPLATE = template0 ENCODING = 'UTF8';
|
||||
\connect keycloak
|
||||
COPY public.realm (id, name) FROM stdin;
|
||||
1 maitre
|
||||
2 exemple
|
||||
\.
|
||||
COPY public.user_session (id) FROM stdin;
|
||||
a
|
||||
\.
|
||||
\connect icingadb
|
||||
COPY public.host (id, name) FROM stdin;
|
||||
\\x0a mon-01
|
||||
\.
|
||||
COPY public.history (id, event_time) FROM stdin;
|
||||
\\x01 1
|
||||
\\x02 2
|
||||
\.
|
||||
"""
|
||||
|
||||
|
||||
def ldif(mdp: str = "e1NTSEF9YW5jaWVu", marie: bool = True, mail: str = "marie@exemple.internal") -> str:
|
||||
t = LDIF.replace("{mdp}", mdp).replace("marie@exemple.internal", mail)
|
||||
if not marie:
|
||||
t = t.split("dn: uid=marie")[0]
|
||||
return t
|
||||
|
||||
|
||||
def lancer(argv: list[str], interroger=None) -> tuple[int, str]:
|
||||
sortie = io.StringIO()
|
||||
with contextlib.redirect_stdout(sortie):
|
||||
rc = T.main(argv, interroger=interroger or T.psql)
|
||||
return rc, sortie.getvalue()
|
||||
|
||||
|
||||
def fichiers() -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
d = Path(d)
|
||||
avant, vivant = d / "avant", d / "vivant"
|
||||
# L'AC : une cle, un certificat, et sa base qui se compacte d'elle-meme.
|
||||
for racine in (avant / str(vivant).lstrip("/"), vivant):
|
||||
ecrire(racine / "step-ca/certs/root_ca.crt", "RACINE")
|
||||
ecrire(racine / "step-ca/secrets/root_ca_key", "CLE")
|
||||
ecrire(racine / "vmail/marie/Maildir/cur/1700.M1.h,S=10:2,S", "bonjour")
|
||||
ecrire(racine / "rspamd/dkim/setops.key", "DKIM")
|
||||
ecrire(racine / "nc/config/config.php", "<?php $CONFIG = array ('instanceid' => 'oc1abc',);")
|
||||
ecrire(racine / "nc/data/marie/files/rapport.odt", "rapport")
|
||||
ecrire(avant / str(vivant).lstrip("/") / "step-ca/db/000001.vlog", "ancien journal")
|
||||
ecrire(vivant / "step-ca/db/000002.vlog", "journal compacte")
|
||||
ecrire(avant / str(vivant).lstrip("/") / "vmail/marie/Maildir/new/1800.M2.h,S=5", "salut")
|
||||
ecrire(vivant / "vmail/marie/Maildir/cur/1800.M2.h,S=5:2,S", "salut") # lu depuis
|
||||
ecrire(vivant / "vmail/marie/Maildir/new/1900.M3.h,S=4", "neuf") # recu depuis
|
||||
ecrire(avant / str(vivant).lstrip("/") / "rspamd/bayes.spam.sqlite", "appris")
|
||||
ecrire(vivant / "rspamd/bayes.spam.sqlite", "appris davantage")
|
||||
ecrire(avant / str(vivant).lstrip("/") / "nc/data/appdata_oc1abc/preview/1.png", "cache")
|
||||
|
||||
jeux = [f"step_ca={vivant}/step-ca", f"courriel={vivant}/vmail", f"rspamd={vivant}/rspamd",
|
||||
f"nextcloud={vivant}/nc/data:{vivant}/nc/config"]
|
||||
base = ["--avant", str(avant), "--instantane", "abcd1234", "--hote", "h"]
|
||||
argv = base + [x for j in jeux for x in ("--jeu", j)]
|
||||
|
||||
rc, out = lancer(argv)
|
||||
verifier(rc == 0, f"la vie ordinaire n'est pas un ecart : base de l'AC compactee, courriel lu et "
|
||||
f"recu, bayes appris, cache Nextcloud refait (code {rc})\n{out}")
|
||||
verifier("step_ca CONFORME" in out and "rspamd CONFORME" in out,
|
||||
"des changements ordinaires sont dits CONFORME, pas IDENTIQUE")
|
||||
verifier("perdu : msg" not in out, "un courriel passe de new/ a cur/ avec un drapeau n'est pas perdu")
|
||||
|
||||
ecrire(vivant / "step-ca/secrets/root_ca_key", "AUTRE CLE")
|
||||
rc, out = lancer(argv)
|
||||
verifier(rc == 1 and "IDENTITE secrets/root_ca_key" in out, "une cle de l'AC changee est un ECART")
|
||||
ecrire(vivant / "step-ca/secrets/root_ca_key", "CLE")
|
||||
|
||||
ecrire(vivant / "rspamd/dkim/setops.key", "AUTRE DKIM")
|
||||
rc, out = lancer(argv)
|
||||
verifier(rc == 1 and "IDENTITE dkim/setops.key" in out, "une cle DKIM changee est un ECART")
|
||||
ecrire(vivant / "rspamd/dkim/setops.key", "DKIM")
|
||||
|
||||
ecrire(vivant / "nc/config/config.php", "<?php $CONFIG = array ('instanceid' => 'ocNEUF',);")
|
||||
rc, out = lancer(argv)
|
||||
verifier(rc == 1 and "instanceid : oc1abc -> ocNEUF" in out, "un instanceid Nextcloud change est un ECART")
|
||||
ecrire(vivant / "nc/config/config.php", "<?php $CONFIG = array ('instanceid' => 'oc1abc',);")
|
||||
|
||||
(vivant / "nc/data/marie/files/rapport.odt").unlink()
|
||||
rc, out = lancer(argv)
|
||||
verifier(rc == 1 and "perdu : marie/files/rapport.odt" in out, "un fichier d'une personne perdu est un ECART")
|
||||
ecrire(vivant / "nc/data/marie/files/rapport.odt", "rapport")
|
||||
|
||||
shutil.rmtree(vivant / "vmail/marie/Maildir/cur")
|
||||
rc, out = lancer(argv)
|
||||
verifier(rc == 1 and "perdu : marie/Maildir/msg/1700.M1.h,S=10" in out, "un courriel perdu est un ECART")
|
||||
|
||||
rc, out = lancer(base)
|
||||
verifier(rc == 0 and "SANS OBJET" in out, "un noeud sans etat n'a rien a comparer")
|
||||
|
||||
|
||||
def annuaire() -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
d = Path(d)
|
||||
ecrire(d / "avant/var/backups/setops/openldap/annuaire.ldif", ldif())
|
||||
vivant = d / "vivant.ldif"
|
||||
argv = ["--avant", str(d / "avant"), "--instantane", "x", "--jeu", "openldap=/var/backups/setops/openldap",
|
||||
"--ldif", "/var/backups/setops/openldap/annuaire.ldif", "--ldif-vivant", str(vivant)]
|
||||
|
||||
# slapadd repose les attributs operationnels, et une ligne pliee n'est pas une autre valeur.
|
||||
ecrire(vivant, ldif().replace("modifyTimestamp: 20261001000000Z", "modifyTimestamp: 20261007160000Z")
|
||||
.replace("mail: marie@exemple.internal", "mail: marie@exemple.inter\n nal"))
|
||||
rc, out = lancer(argv)
|
||||
verifier(rc == 0 and "openldap IDENTIQUE" in out,
|
||||
f"attributs operationnels et pliage ignores : IDENTIQUE ({out.strip()[-160:]})")
|
||||
|
||||
ecrire(vivant, ldif(mdp=base64.b64encode(b"{SSHA}amorcage").decode()))
|
||||
rc, out = lancer(argv)
|
||||
verifier(rc == 1 and "IDENTITE userPassword : uid=sysadmin" in out,
|
||||
"le mot de passe de sysadmin remis a l'amorcage est un ECART (la panne du 2026-09-30)")
|
||||
|
||||
ecrire(vivant, ldif(marie=False))
|
||||
rc, out = lancer(argv)
|
||||
verifier(rc == 1 and "perdue : uid=marie" in out, "une entree perdue est un ECART")
|
||||
|
||||
ecrire(vivant, ldif(mail="marie@ailleurs.internal"))
|
||||
rc, out = lancer(argv)
|
||||
verifier(rc == 0 and "CONFORME" in out and "(mail)" in out,
|
||||
"un attribut ordinaire modifie est dit, sans ecart")
|
||||
|
||||
|
||||
def postgresql() -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
d = Path(d)
|
||||
ecrire(d / "avant/var/backups/setops/postgresql/toutes-bases.sql", DUMP)
|
||||
argv = ["--avant", str(d / "avant"), "--instantane", "x", "--jeu", "postgresql=/var/backups/setops/postgresql",
|
||||
"--dump", "/var/backups/setops/postgresql/toutes-bases.sql"]
|
||||
AVANT = {"public.realm": ["1", "2"], "public.user_session": ["a"],
|
||||
"public.host": ["\\\\x0a"], "public.history": ["\\\\x01", "\\\\x02"]}
|
||||
|
||||
def cluster(bases=("postgres", "template0", "template1", "keycloak", "icingadb"),
|
||||
roles=("postgres", "keycloak"), **tables):
|
||||
contenu = {**AVANT, **tables}
|
||||
|
||||
def interroger(base: str, sql: str):
|
||||
if "pg_database" in sql:
|
||||
# Comme PostgreSQL : `template1` est un modele, qu'un filtre sur
|
||||
# `datistemplate` ecarterait (le faux « base perdue » du premier essai reel).
|
||||
return [b for b in bases if not ("datistemplate" in sql and b == "template1")]
|
||||
if "pg_roles" in sql:
|
||||
return list(roles)
|
||||
table = sql.split(" from ", 1)[1].split(")", 1)[0]
|
||||
return list(contenu[table]) if contenu.get(table) is not None else None
|
||||
return interroger
|
||||
|
||||
rc, out = lancer(argv, cluster())
|
||||
verifier(rc == 0 and "postgresql IDENTIQUE 4 table(s)" in out and "template0" not in out,
|
||||
f"memes bases, roles et cles : IDENTIQUE ({out.strip()[-120:]})")
|
||||
|
||||
rc, out = lancer(argv, cluster(**{"public.user_session": ["b", "c"]}))
|
||||
verifier(rc == 0 and "public.user_session : 1 retiree(s), 2 nouvelle(s)" in out,
|
||||
"des sessions renouvelees : dit, sans ecart (la vie ordinaire de Keycloak)")
|
||||
|
||||
rc, out = lancer(argv, cluster(**{"public.history": ["\\\\x01", "\\\\x02", "\\\\x03"]}))
|
||||
verifier(rc == 0 and "1 nouvelle(s)" in out, "un historique qui s'allonge : dit, sans ecart")
|
||||
|
||||
rc, out = lancer(argv, cluster(**{"public.history": ["\\\\x02", "\\\\x03"]}))
|
||||
verifier(rc == 1 and "historique perdu : icingadb.public.history : 1 ligne(s)" in out,
|
||||
"une ligne d'historique perdue est un ECART")
|
||||
|
||||
# Le cas reel du 2026-10-07 : meme nombre de lignes, aucune en commun. Compter ne le
|
||||
# voyait pas ; c'etait la base d'Icinga, non restauree.
|
||||
rc, out = lancer(argv, cluster(**{"public.host": ["\\\\xff"], "public.history": ["\\\\x08", "\\\\x09"]}))
|
||||
verifier(rc == 1 and "base icingadb : aucune des 3 ligne(s) d'avant ne subsiste" in out,
|
||||
"une base entierement renouvelee, a nombre de lignes egal, est un ECART")
|
||||
|
||||
rc, out = lancer(argv, cluster(bases=("postgres", "template1", "icingadb")))
|
||||
verifier(rc == 1 and "base perdue : keycloak" in out, "une base perdue est un ECART")
|
||||
|
||||
rc, out = lancer(argv, cluster(roles=("postgres",)))
|
||||
verifier(rc == 1 and "role perdu : keycloak" in out, "un role perdu est un ECART")
|
||||
|
||||
rc, out = lancer(argv, cluster(**{"public.realm": None}))
|
||||
verifier(rc == 1 and "table perdue : keycloak.public.realm" in out, "une table perdue est un ECART")
|
||||
|
||||
|
||||
def icinga() -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
d = Path(d)
|
||||
vivant = d / "vivant"
|
||||
for racine in (d / "avant" / str(vivant).lstrip("/"), vivant):
|
||||
ecrire(racine / "icinga2/ca/ca.crt", "AC")
|
||||
ecrire(racine / "icinga2/ca/ca.key", "CLE")
|
||||
ecrire(racine / "icinga2/icingadb.env", '"37503b53fd8"')
|
||||
ecrire(vivant / "icinga2/ca/serial.txt", "02")
|
||||
argv = ["--avant", str(d / "avant"), "--instantane", "x",
|
||||
"--jeu", f"icinga={vivant}/icinga2/ca:{vivant}/icinga2/icingadb.env"]
|
||||
rc, out = lancer(argv)
|
||||
verifier(rc == 0, f"l'AC d'Icinga remise, un certificat de noeud signe depuis : sans ecart ({out.strip()[-120:]})")
|
||||
ecrire(vivant / "icinga2/icingadb.env", '"0afe46c3"')
|
||||
rc, out = lancer(argv)
|
||||
verifier(rc == 1 and "IDENTITE icingadb.env" in out,
|
||||
"un environnement d'Icinga DB change est un ECART (l'historique deviendrait orphelin)")
|
||||
ecrire(vivant / "icinga2/icingadb.env", '"37503b53fd8"')
|
||||
ecrire(vivant / "icinga2/ca/ca.key", "AUTRE CLE")
|
||||
rc, out = lancer(argv)
|
||||
verifier(rc == 1 and "IDENTITE ca.key" in out, "une cle d'AC d'Icinga changee est un ECART")
|
||||
|
||||
|
||||
def bloc(racine: Path, nom: str, debut: int, fin: int) -> None:
|
||||
ecrire(racine / nom / "meta.json", json.dumps({"minTime": debut, "maxTime": fin}))
|
||||
ecrire(racine / nom / "chunks" / "000001", nom)
|
||||
|
||||
|
||||
def prometheus_loki() -> None:
|
||||
"""Prometheus fusionne ses blocs et la retention en retire : on juge la COUVERTURE.
|
||||
Loki : aucun morceau d'avant = non restaure ; une perte partielle = retention."""
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
d = Path(d)
|
||||
staging = "/var/backups/setops/prometheus"
|
||||
avant = d / "avant" / staging.lstrip("/") / "metrics2"
|
||||
vivant = d / "vivant"
|
||||
U = "01" + "A" * 24
|
||||
bloc(avant, U[:-1] + "1", 1000, 2000)
|
||||
bloc(avant, U[:-1] + "2", 2000, 3000) # le dernier bloc d'avant commence a 2000
|
||||
argv = ["--avant", str(d / "avant"), "--instantane", "x", "--jeu", f"prometheus={staging}",
|
||||
"--vivant", f"prometheus={vivant}"]
|
||||
|
||||
bloc(vivant, U[:-1] + "9", 1000, 3000) # restaure, puis fusionne en un bloc
|
||||
bloc(vivant, U[:-1] + "8", 3000, 4000)
|
||||
rc, out = lancer(argv)
|
||||
verifier(rc == 0, f"blocs restaures puis fusionnes : sans ecart ({out.strip()[-140:]})")
|
||||
|
||||
shutil.rmtree(vivant)
|
||||
bloc(vivant, U[:-1] + "7", 9000, 9500) # une base nee apres la reconstruction
|
||||
rc, out = lancer(argv)
|
||||
verifier(rc == 1 and "non restaurees" in out, "une base de Prometheus non restauree est un ECART")
|
||||
|
||||
shutil.rmtree(vivant)
|
||||
bloc(vivant, U[:-1] + "6", 2000, 4000) # la retention a retire le plus ancien
|
||||
rc, out = lancer(argv)
|
||||
verifier(rc == 0 and "retention" in out, "la retention qui retire le plus ancien bloc : dite, sans ecart")
|
||||
|
||||
# Une base de moins de deux heures : pas de bloc, le WAL fait le repere.
|
||||
shutil.rmtree(avant)
|
||||
shutil.rmtree(vivant)
|
||||
ecrire(avant / "wal" / "00000003", "w")
|
||||
ecrire(vivant / "wal" / "00000003", "w")
|
||||
ecrire(vivant / "wal" / "00000004", "w")
|
||||
rc, out = lancer(argv)
|
||||
verifier(rc == 0, "sans bloc, le WAL d'avant retrouve dans la base vivante : sans ecart")
|
||||
shutil.rmtree(vivant)
|
||||
ecrire(vivant / "wal" / "00000000", "w")
|
||||
rc, out = lancer(argv)
|
||||
verifier(rc == 1 and "WAL d'avant" in out, "sans bloc, un WAL neuf a la place de celui d'avant : ECART")
|
||||
|
||||
staging = "/var/backups/setops/loki"
|
||||
avant = d / "avant" / staging.lstrip("/") / "donnees"
|
||||
vivant = d / "loki"
|
||||
for racine in (avant, vivant):
|
||||
ecrire(racine / "chunks" / "fake" / "a", "1")
|
||||
ecrire(racine / "chunks" / "fake" / "b", "2")
|
||||
ecrire(vivant / "chunks" / "fake" / "c", "3")
|
||||
argv = ["--avant", str(d / "avant"), "--instantane", "x", "--jeu", f"loki={staging}",
|
||||
"--vivant", f"loki={vivant}"]
|
||||
rc, out = lancer(argv)
|
||||
verifier(rc == 0 and "1 morceau(x) nouveau(x)" in out, "Loki restaure, des journaux arrives depuis : sans ecart")
|
||||
(vivant / "chunks" / "fake" / "a").unlink()
|
||||
rc, out = lancer(argv)
|
||||
verifier(rc == 0 and "retire(s)" in out, "un morceau d'avant retire (retention) : dit, sans ecart")
|
||||
(vivant / "chunks" / "fake" / "b").unlink()
|
||||
rc, out = lancer(argv)
|
||||
verifier(rc == 1 and "journaux non restaures" in out, "aucun morceau d'avant : Loki non restaure, ECART")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
fichiers()
|
||||
annuaire()
|
||||
postgresql()
|
||||
icinga()
|
||||
prometheus_loki()
|
||||
if ECHECS:
|
||||
print(f"\n{len(ECHECS)} echec(s).")
|
||||
return 1
|
||||
print("\nLes temoins voient une perte ou une identite changee, et laissent passer la vie ordinaire.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -62,8 +62,21 @@ def tunnels(inclure_vides: bool = False) -> list[dict]:
|
|||
"pairs": {str(p["nom"]): p for p in (site.get("pairs") or [])}})
|
||||
for nom_depot, _prefixe, _n in devis_reseau.decouvrir_du_site():
|
||||
base = RACINE.parent / nom_depot
|
||||
registre = charger_acces(base / "plan" / "acces.yml")
|
||||
index = charger_nomenclature(base / "plan" / "nomenclature.yml").get("index")
|
||||
# LA FACE RESEAU D'ABORD (2026-10-05, etape 3) : le locataire publie ses pairs et son
|
||||
# index ; le site ne lit plus son `plan/acces.yml` ni sa nomenclature.
|
||||
try:
|
||||
import contexte
|
||||
face = contexte.Locataire(base).face_publiee()
|
||||
except Exception:
|
||||
face = None
|
||||
if face is not None:
|
||||
registre = {"acces_admin_vpn": dict((face.get("administration") or {}).get("pairs") or {})}
|
||||
index = face.get("index")
|
||||
else:
|
||||
print(f"note : {nom_depot} n'a pas publie sa face reseau — lecture de son plan "
|
||||
f"(transition).", file=sys.stderr)
|
||||
registre = charger_acces(base / "plan" / "acces.yml")
|
||||
index = charger_nomenclature(base / "plan" / "nomenclature.yml").get("index")
|
||||
# UN LOCATAIRE SANS PAIR N'A PAS DE TUNNEL — sauf quand on vient justement lui en
|
||||
# proposer un (`pair-nouveau`) : refuser la` aurait fait de la premiere cle un
|
||||
# probleme d'oeuf et de poule, et c'est ce que le premier essai a montre.
|
||||
|
|
|
|||
Loading…
Reference in a new issue