Compare commits

..

49 commits

Author SHA1 Message Date
ee0e33d452 flux : plus aucune sortie vers Internet sans un role qui le dise
La sortie 80/443 que le socle donnait a toute machine ne servait plus a apt
(tout passe par le cache du site) mais a de la telemetrie et a des sorties
non declarees. Elle devient conditionnelle (seulement_si artefacts_amorcage
vide) ; la telemetrie de Grafana, Loki, Alloy et la verification de version
de Collabora sont coupees a la source ; rspamd et la sonde du genome
declarent leur sortie. Le devis du site evalue enfin seulement_si, ce qui
retire aussi le 465/587 vers un Postfix du site qui n'ecoute que sur 25.
Garde : test_conditions_site.py. Journal (125).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:11:13 -04:00
dd4ca09245 pare-feu : conserver et réconcilier les raisons des flux 2026-10-09 19:56:28 -04:00
162604ad24 journal : les deux locataires reconstruits sur 34e961f, sans PyPI ni Galaxy
(122) Technolibre : trois passages, le dernier de bout en bout en 36 min ;
runner monte depuis le site seulement, pip freeze egal au verrou.
(123) Chezlepro : 43 min d'une traite, memes constats.

94 preuves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:22:29 -04:00
34e961fb61 serveur_ops : la liste hors ligne des collections porte les vraies versions
Le requirements.yml genere portait version: \1 (regex_replace dans un bloc
YAML) ; ansible-galaxy refusait, et la reprise de la reconstruction de
Technolibre s'est arretee apres avoir pris roues et collections au site.
Version tiree du nom par decoupage, sans expression reguliere ; rejoue sur
le poste, les trois collections s'installent hors ligne.
test_verrou_runner.py rend desormais le fichier avec Jinja2 et compare.

94 preuves. CHANGELOG (121).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:35:18 -04:00
422b68846b serveur_ops : creer les depots de roues et de collections avant la copie
La copie fichier par fichier du verrou ne cree pas son dossier, contrairement
a l'ancienne copie du cache entier. La reconstruction de Technolibre s'est
arretee a l'insemination sur un runner neuf (roues bien prises au site).
test_verrou_runner.py exige desormais que chaque dossier vise soit cree avant.

CHANGELOG (121) : l'arret, et le blocage site-pki-01 -> 10.37.32.1:8006 vu a
la frontiere, qui est la sonde de connectivite (resultat attendu).

94 preuves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:22:01 -04:00
55f42add15 serveur_ops : ne pas accuser le depot du site en simulation
En --check, rien n'est telecharge depuis le site : le cache du controleur
reste vide et les messages annoncaient les 15 roues et les 3 collections
absentes du depot. Ils se taisent desormais en simulation.

CHANGELOG (121) : serveur_artefacts deploye au site, 18 sommes servies
conformes depuis ops-01, serveur_ops simule sur ce runner.

94 preuves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:35:23 -04:00
4ebca13058 runner : roues et collections prises au site, plus a PyPI ni a Galaxy
verrou-runner.yml fige les 15 roues et les 3 collections qui tournent sur le
runner de Technolibre, avec URL amont et somme SHA-256. Le cache du runner
tenait deux versions de six bibliotheques selon le jour du montage.

- serveur_artefacts depose ces 18 fichiers au depot de binaires du site.
- serveur_ops les prend au site d'abord, somme verifiee ; repli sur Internet
  dit en clair ; plus aucun pip download ni collection download ; seuls les
  fichiers du verrou sont deposes sur le runner.
- ansible-core epingle a 2.18.19 (la version deployee).
- P70 exige le verrou au site ; test_verrou_runner.py dans make test.

AGENTS.md et CLAUDE.md : version allegee par l'operateur.

94 preuves. CHANGELOG (121).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:24:38 -04:00
279e583701 journal : les deux locataires reconstruits sur 3afb756, sans l'app store
Technolibre (43 min) et Chezlepro (44 min), d'une traite : 10 jeux restaures, temoins sans ecart, minuteurs apt remis a l'heure sur 12 VM, applications Nextcloud prises au site. Code a etiqueter : v2026.10.09.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:01:22 -04:00
3afb756993 common_packages : pas de rattrapage des minuteurs apt herite du gabarit
Les minuteurs apt persistants d'un clone portaient la trace du gabarit (2026-06-21) et rattrapaient un passage dans l'heure suivant la naissance : apt-daily-upgrade a pris le verrou du cache des archives en plein amorcage d'infra-pki-01. Une trace plus ancienne que la machine est remise a l'heure et le minuteur relance, avant toute operation apt. Les correctifs restent automatiques.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:49:37 -04:00
c7378ed7f7 nextcloud : les applications viennent du depot du site, plus de l'app store
user_oidc et richdocuments epinglees (version, sha256), tenues par site-cache-01 dans le depot de binaires, installees par app_tierce.yml : site d'abord, amont en repli dit, somme puis signature (occ integrity:check-app), activation. P70 comprend les listes d'artefacts. L'app store a 77 ko/s avait arrete la reconstruction de Technolibre.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:47:33 -04:00
7a4c55bfc9 journal : sonde corrigee deployee, sauvegardes du site OK ; une fausse alerte sur le mot de passe d'API
client_backup (Technolibre, site) et serveur_backup (site) deployes ; les six services de sauvegarde du site OK apres relance. La difference de mot de passe vue en simulation n'etait qu'un blanc de fin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:27:55 -04:00
0f7c89fbb5 sondes de sauvegarde : juger le plus recent instantane ; temoin Loki hors index
restic snapshots --latest 1 rend un instantane par groupe (machine et chemins) : la sonde gardait le premier, l'ancien, depuis qu'un jeu a ete ajoute a site-mon-01 (EN RETARD a tort, PERIME promis a 50 h). Meme motif cote serveur_backup. Le temoin de Loki ne compte plus l'index compacte. Journal (114), (115).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:35:28 -04:00
9ca9a5ffe1 valider : la recette Prometheus attend une minute au plus que ses cibles soient relevees
La reconstruction de Technolibre s'est arretee : la recette a juge Prometheus 18 s apres sa relance par le premier depot d'obs-01. Elle redemande desormais, puis juge. Journal (112) Keycloak et (113).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:20:45 -04:00
2dce6eabd5 journal : catalogue des capteurs au site ; icingadb-redis mis a jour sans que la simulation le montre
serveur_icinga deploye au site : catalogue pose, epinglage des paquets tiers. icingadb-redis 8.2.9 -> 8.2.10 (installe depuis le cache, invisible en --check) ; Icinga 2 et Icinga DB reconnectes. VM 99998 supprimee par l'exploitant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:16:00 -04:00
479ec3cc7d journal : l'arret de Loki attend un envoi d'Alloy (10 s), pas la copie
Correction de (109) : la liaison des morceaux fonctionne (copie ~60 ms) ; les 10,75 s viennent de l'arret propre de Loki, qui attend un envoi d'Alloy jusqu'a son delai de 10 s. Aucune perte mesuree ; laisse tel quel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:54:14 -04:00
9a4b63544e loki : les morceaux sont lies, pas copies ; chunks/index reste copie
Copier 6 550 morceaux arretait Loki 13 s au site ; les lier prend 40 ms. Ils sont adresses par leur contenu ; chunks/index loge delete_requests.gz, qui se reecrit, et reste vraiment copie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:44:04 -04:00
3e2846cc5f journal : le site recoit l'outillage de restauration et la copie a froid de Prometheus et Loki
Les machines sauvegardees du site n'avaient pas setops-restaurer. client_backup deploye au site : 0 echec ; Prometheus arrete 523 ms, un releve manque ; Loki 13 s, aucun journal perdu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:34:24 -04:00
cca22f47ca journal : Prometheus et Loki deployes chez les deux locataires, un seul releve manque
Frontiere sans changement ; pare-feu Proxmox et nftables de mon-01 ouverts a obs-01 ; premier depot : Prometheus arrete 85 a 151 ms, Loki 582 a 614 ms, ecart maximal 30 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:14:52 -04:00
4660a8892a prometheus et loki : copie a froid, restauration et temoins
Les metriques et les journaux repartaient de zero a chaque reconstruction. setops-copie-a-froid arrete le service le temps de copier et le relance quoi qu il arrive ; les roles remettent la copie avant de demarrer ; les temoins jugent la couverture, pas les fichiers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:57:04 -04:00
6141bc4b2f journal : le gabarit 99998 sort des declarations
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:17:00 -04:00
e8907285d5 publier : les releases voyagent jusqu'aux deux forges ; wiki publie sur la forge du site
Le colis du genome ne portait que la branche : les etiquettes de release
n'arrivaient jamais sur la forge du site. Il les porte desormais, le runner les
pousse sans forcer, et la forge est relue par son API. publier.py pousse aussi
les etiquettes vers eregion. Le wiki, jamais publie sur la forge du site, l'est.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:04:31 -04:00
e9215a2141 journal : les deux locataires reconstruits sur 2cbfa2d, l'historique d'Icinga traverse
Technolibre puis Chezlepro, 43 min chacun, temoins sans ecart. L'AC d'Icinga
restauree garde l'environnement ; l'historique d'avant reste, 0 ligne orpheline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:12:05 -04:00
2cbfa2df9b journal : client_backup et serveur_icinga deployes, les temoins par cle voient l'historique perdu
Sur les deux locataires : l'AC d'Icinga est desormais sauvegardee. Rejoues sur
Chezlepro, les temoins par cle voient les 412 lignes d'historique perdues que le
comptage cachait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:35:35 -04:00
f04e790d2c icinga : l'AC et l'historique survivent a la reconstruction ; temoins par cle
L'historique d'Icinga DB etait exclu de la restauration (2026-09-30) : son
environnement derive de l'AC d'Icinga, recreee a chaque reconstruction. L'AC est
desormais un jeu de sauvegarde, remis avant api setup ; la base d'Icinga est
restauree, et son schema n'est importe que sur une base vierge.

Les temoins comparaient PostgreSQL par nombre de lignes et n'ont pas vu 412
lignes d'historique remplacees par 380 neuves. Ils comparent desormais par cle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:18:40 -04:00
12caff1cff devis de placement : le gabarit et le stockage que le clonage utilise (ceux du site)
Le devis lisait le gabarit du tenant : il validait chez Technolibre un 99998 que
plus rien ne clone, et refusait Chezlepro, qui suit la regle et n'en declare plus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:24:39 -04:00
543791e859 temoins : toutes les bases, modeles compris (template1 dite perdue a tort)
Premier essai reel sur Technolibre : pg_dumpall --clean recree template1, et
les bases vivantes etaient lues sans les modeles. Le test reproduit le filtre.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:55:53 -04:00
11b5bb5733 temoins : l'instantane d'avant rasage est garde, et l'etat remis lui est compare
M4 a reconstruit Technolibre par le site qui la nomme ; les 7 jeux sont revenus
de l'instantane de 11:34, mais le premier depot d'apres reconstruction l'a chasse
par --keep-daily le jour meme : plus rien a quoi comparer.

Le depot d'avant rasage est etiquete avant-raser et garde jusqu'a la
reconstruction suivante. Le bilan dit ce qui a ete restaure et si c'est encore
au depot. setops-restaurer temoins et make temoins-etat comparent l'etat vivant
a cet instantane : une perte ou une identite changee est un ecart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:48:00 -04:00
a204ead8ab pre-vol M4 : raser et le devis de placement nomment le locataire designe
Sur le chemin TENANT=, raser annoncait un ecosysteme monte (rien n'est
monte sur le runner du site) et le devis de placement un tenant « ? ».
Les deux en-tetes disent desormais ce qui est vise et d'ou vient la liste.
Le pre-vol lui-meme est consigne : 13 VM, aucun conflit, placement conforme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:16:04 -04:00
e7f1a74040 contexte : materialisation M3, la reconstruction nomme son locataire
reconstruire_locataire lance locataire-raser et locataire-creer TENANT= sur
le runner du site, sans monter le depot du locataire comme instance.

Trou de M2 corrige avant tout usage : le sous-make cloner-vm nommait le pool
par l'instance montee (hors de tout pool sur le site, pool de l'autre
locataire sur le poste). devis_proxmox_pools.py --pool-du-locataire le
derive de la face.

test_appels_locataire.py : un faux ansible-playbook consigne la ligne qui
part ; 26 machines, ancien chemin, site et poste identiques a l'argument
pres. Temoin : l'ancienne ligne de pool fait echouer le test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:27:25 -04:00
8294e5dc71 contexte : materialisation M2, les commandes du site nomment leur locataire
locataire-creer, locataire-raser et placement-plan TENANT= lisent la face
reseau du locataire, sans monter son depot comme instance. flotte-creer,
creer-vm et raser acceptent TENANT= ; sans lui, l'ancien chemin est inchange.

P94 : creer, raser et placer par la face visent les memes machines, VMID,
valeurs et ponts que l'ancien chemin, chacun mesure par sa commande lancee
a part. 116 controles dans test_contexte.py, temoins compris.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:08:21 -04:00
4532a98707 contexte : materialisation M1, la face publie les parametres de clonage (P93)
Chaque machine porte ce que parametres-proxmox rend, par la meme fonction.
P93 compare a la commande lancee a part pour les 26 machines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:22:43 -04:00
40af2d1ec6 contexte : etape 3, les pools et les tunnels lisent la face reseau
devis_proxmox_pools et vpn_admin prennent machines, VMID, etat, pairs et
index dans la face publiee. Sorties identiques a un worktree complet de
la version precedente ; temoins d'alteration verts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:59:29 -04:00
2e9b31bef7 contexte : corriger le devis des pools vide (face sans fonctions)
La face publie aussi fonctions ; les pools retrouvent leurs VM. La
comparaison a l'ancien code est refaite dans un git worktree complet :
les sept sorties du site sont identiques. Garde de regression ajoutee.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:49:24 -04:00
d0a6002b87 contexte : etape 3, le site decouvre ses locataires par leur face reseau
decouvrir_du_site part de underlay.tenants et prend index et zones dans
la face publiee. Les sept sorties du site sont identiques a HEAD, a
l'octet pres ; frontiere-plan ne voit rien a faire.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:10:39 -04:00
013c1b5279 contexte : etape 3, la frontiere lit la face reseau
devis_opnsense prend dans la face publiee les adresses des groupes,
l'intrant d'administration, les verdicts, le tunnel et les zones
publiees. Devis identique a l'octet pres, a HEAD comme par le repli.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:02:03 -04:00
8c1e8c725b contexte : etape 3, le pare-feu Proxmox lit la face reseau
La face publie le verdict des flux conditionnels ; devis_proxmox_fw y lit
inventaire, verdicts et administration. Devis identique, octet pour octet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:51:37 -04:00
44c24e62e4 contexte : etape 3, le locataire publie sa face reseau, le site la lit (P92)
face-reseau.yml publiee chez chaque locataire (make face-reseau-publier) ;
les comptes de sauvegarde et le DNS public du site la lisent au lieu des
fichiers internes. Inventaire du site identique, octet pour octet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:39:28 -04:00
2beef04dc6 contexte : etape 3, l'instancier lit la fiche du site (P91)
Le site depose fiche-site.yml chez ses locataires (make fiches-site-
deposer) ; l'instancier y lit temps, delegation DNS et routage. Sans le
site, l'inventaire genere est identique au versionne, a l'octet pres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:23:17 -04:00
d6591cb271 contexte : etape 2 terminee, les sorties (P90)
resoudre_flux publie les sorties vers l'Internet de chaque machine ;
verifier_sorties les confronte aux regles de sortie de la frontiere.
Le contrat site <-> locataire est entierement publie et prouve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:21:37 -04:00
92a6fb0ead nftables_baseline : la passerelle n'est pas une porte (garde 8006)
La sonde connectivite essaie l'API Proxmox (8006) sur la passerelle par
defaut : chez un locataire en SDN, c'est l'hyperviseur dans son VRF, hors
de la frontiere. Ouvert -> CRITIQUE. Temoin : CONNECTIVITE_PASSERELLE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:12:37 -04:00
ce84d0e1ea frontiere : l'administration (P89) ; serveur_ops ne declare plus le 8006
La face reseau dit quelles entrees publiques sont ouvertes au poste ;
verifier_administration confronte gestion, VPN et tunnel. La sortie 8006
de serveur_ops vers externe ne laissait passer aucun paquet (hyperviseurs
en plages privees) et affirmait un pouvoir qu aucun locataire n a.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:14:14 -04:00
56033afd00 serveur_ops : retirer la sortie 443 vers voisins_site (passage inter-locataires)
Ecrite quand la forge du genome vivait chez un voisin ; elle est au site.
Vue d un locataire, voisins_site designe les autres locataires : la
frontiere ouvrait le 443 de chaque runner vers le supernet de l autre.
Le clonage passe par la regle de serveur_forge_site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:38:18 -04:00
3c16ef45cd contexte : etape 2, la frontiere second temps, les entrees publiques (P88)
La face reseau porte le port de son tunnel ; verifier_entrees_publiques
confronte regles WAN, redirections et tunnel aux entrees que la face
ouvre a tous. Aucun ecart ; quatre alterations vues.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:14:32 -04:00
78c2c25761 contexte : etape 2, la frontiere premier temps, les identites (P87)
La face reseau porte ses zones ; verifier_frontiere confronte supernet,
administration, tunnel, alias de groupe, routes et traduction sortante a
la face reseau et a la fiche du site. Aucun ecart ; cinq alterations vues.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:28:06 -04:00
3d01208a68 contexte : etape 2, les flux de chaque machine (P86)
La face reseau porte les flux resolus par le locataire ; verifier_flux
les confronte au devis Proxmox, port par port. resoudre_flux publie les
sources_declarees d un port aussi public, que le locataire jetait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:10:12 -04:00
077c6c91b4 contexte : etape 2b, la face reseau du locataire (les faits)
locataire.face_reseau() publie index, machines, groupes, administration,
zones publiques et cle de sauvegarde ; verifier_face la confronte aux
consommateurs du site (decouverte, devis Proxmox, frontiere, inventaire
du site). P85 : aucun ecart ; huit alterations vues en ecart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:02:29 -04:00
8251022148 contexte : etape 2a, la fiche du site et la preuve qu elle dit vrai
site.fiche_pour(locataire) reunit attribution, offre, racine et les
trois lectures directes de l instancier ; verifier_fiche la confronte
aux copies, a la racine et a l inventaire du locataire. P84 : aucune
ecart sur les deux couples ; sept alterations vues en ecart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:16:55 -04:00
c891d7fb26 contexte : etape 1, le tronc commun et les deux classes (inutilises)
scripts/contexte.py : Ecosysteme, Site, Locataire, contexte actif
(fichier `contexte`, sinon les indices d avant via instance_courante
et underlay.chemin). 40 controles dans make test. Corrige aussi P34 et
P48, rouges depuis le commit de la page de conception.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:43:20 -04:00
42e23885cd conception : un tronc commun, deux classes (SITE et LOCATAIRE)
docs/conception-contextes.md, arretee avec l exploitant : le releve
des devinettes de contexte et des echanges site <-> locataire, le
contrat en deux fiches, le poste selecteur, le chemin en neuf etapes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:26:32 -04:00
88 changed files with 9012 additions and 942 deletions

3
.gitignore vendored
View file

@ -9,6 +9,9 @@ facts_cache/
**/vault.yml **/vault.yml
# Underlay = fabric physique de l'operateur (cluster-global) ; gabarit public seul. # Underlay = fabric physique de l'operateur (cluster-global) ; gabarit public seul.
/underlay.yml /underlay.yml
# Contexte actif de CETTE machine (`site:<depot>` ou `locataire:<depot>`) : propre a chaque
# poste et a chaque runner, jamais versionne. Voir scripts/contexte.py.
/contexte
*.secret *.secret
*.pem *.pem
*.key *.key

996
AGENTS.md

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -4,24 +4,3 @@
`AGENTS.md` est la **source d'autorité unique** de ce dépôt. Claude Code doit le lire `AGENTS.md` est la **source d'autorité unique** de ce dépôt. Claude Code doit le lire
et le respecter intégralement avant toute modification. et le respecter intégralement avant toute modification.
En cas de contradiction entre `CLAUDE.md` et `AGENTS.md`, **suivre `AGENTS.md`**.
Ce fichier est volontairement mince : il ne redéclare pas la doctrine (template, SSH,
pare-feu, cloud-init, handlers, Makefile, groupes…). Toute cette doctrine vit dans
`AGENTS.md`, pour éviter la duplication et les dérives qu'elle provoque.
## Les cinq règles absolues
1. **Un seul agent IA** travaille dans ce dépôt à la fois (Codex *ou* Claude, jamais les deux).
2. **`instance/inventories/*/hosts.yml` est un artefact GÉNÉRÉ** depuis le plan
(`instance/plan/`). On l'édite jamais à la main : on édite le plan, puis
`make instancier` → `make instancier-appliquer`.
3. **Aucun secret en clair** (mots de passe, clés privées, tokens, certificats) — Vault
ou stockage hors dépôt uniquement.
4. **Toute action destructive ou risquée exige une confirmation explicite** (ex.
`CONFIRMER=true`, `confirm_destructive_action: true`). Sans elle, refuser l'exécution.
5. **Rien n'est « prêt » sans validation.** Au minimum `--syntax-check` du playbook
touché, `ansible-lint` si disponible, et l'entrée `CHANGELOG.md` correspondante.
Le détail de chacune de ces règles, et tout le reste, est dans `AGENTS.md`.

View file

@ -317,6 +317,11 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire)
python3 scripts/tests/test_inventory_host.py python3 scripts/tests/test_inventory_host.py
python3 scripts/tests/test_raser.py python3 scripts/tests/test_raser.py
python3 scripts/tests/test_raser_resultat.py python3 scripts/tests/test_raser_resultat.py
python3 scripts/tests/test_temoins.py
python3 scripts/tests/test_genome_colis.py
python3 scripts/tests/test_verifier_depot.py
python3 scripts/tests/test_apps_nextcloud.py
python3 scripts/tests/test_verrou_runner.py
python3 scripts/tests/test_adressage_derive.py python3 scripts/tests/test_adressage_derive.py
python3 scripts/tests/test_gui_intrants.py python3 scripts/tests/test_gui_intrants.py
python3 scripts/tests/test_runbooks.py python3 scripts/tests/test_runbooks.py
@ -329,10 +334,14 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire)
python3 scripts/tests/test_empreinte_plancher.py python3 scripts/tests/test_empreinte_plancher.py
python3 scripts/tests/test_expositions_sans_port.py python3 scripts/tests/test_expositions_sans_port.py
python3 scripts/tests/test_proxmox_fw_externe.py python3 scripts/tests/test_proxmox_fw_externe.py
python3 scripts/tests/test_libelles_parefeu.py
python3 scripts/tests/test_conditions_site.py
python3 scripts/tests/test_sonde_tcp.py python3 scripts/tests/test_sonde_tcp.py
python3 scripts/tests/test_restauration.py python3 scripts/tests/test_restauration.py
python3 scripts/tests/test_repertoires_partages.py python3 scripts/tests/test_repertoires_partages.py
python3 scripts/tests/test_frontiere_refus.py python3 scripts/tests/test_frontiere_refus.py
python3 scripts/tests/test_contexte.py
python3 scripts/tests/test_appels_locataire.py
# LA SONDE QUI RAPPORTE CE QUI DISTINGUE (2026-08-28). Trois faux diagnostics en une # LA SONDE QUI RAPPORTE CE QUI DISTINGUE (2026-08-28). Trois faux diagnostics en une
# journee, tous dus a l'instrument : `curl` et `bash /dev/tcp` ecrasent « la machine # journee, tous dus a l'instrument : `curl` et `bash /dev/tcp` ecrasent « la machine
@ -609,7 +618,7 @@ site: ansible-runtime ## Regenere playbooks/site.yml depuis les couches et le gr
site-verifier: ## Verifie que playbooks/site.yml correspond aux couches declarees site-verifier: ## Verifie que playbooks/site.yml correspond aux couches declarees
python3 scripts/orchestrer.py verifier python3 scripts/orchestrer.py verifier
.PHONY: flux flux-verifier .PHONY: flux flux-verifier fiches-site-deposer
cles-recenser: ## Montre ce qui n'existe QUE sur ce poste (sans rien ecrire) cles-recenser: ## Montre ce qui n'existe QUE sur ce poste (sans rien ecrire)
python3 scripts/exporter_cles.py --recenser python3 scripts/exporter_cles.py --recenser
@ -745,8 +754,8 @@ proxmox-fw-activer-vm: ansible-runtime ## Active le pare-feu d'UNE VM en le prou
.PHONY: proxmox-fw-eprouver proxmox-fw-activer-vm .PHONY: proxmox-fw-eprouver proxmox-fw-activer-vm
.PHONY: placement-plan sdn-plan sdn-appliquer .PHONY: placement-plan sdn-plan sdn-appliquer
placement-plan: ansible-runtime ## Le noeud, stockage, pont et gabarit du tenant existent-ils sur ce cluster ? (aucune ecriture) placement-plan: ansible-runtime ## Le noeud, stockage, pont et gabarit du tenant existent-ils sur ce cluster ? (aucune ecriture) [TENANT=<depot>]
python3 scripts/devis_placement.py python3 scripts/devis_placement.py $(if $(TENANT),--locataire $(TENANT))
sdn-plan: ansible-runtime ## Ecart entre le SDN EVPN (+ sortie des VRF) et son devis (aucune ecriture) sdn-plan: ansible-runtime ## Ecart entre le SDN EVPN (+ sortie des VRF) et son devis (aucune ecriture)
python3 scripts/appliquer_sdn.py python3 scripts/appliquer_sdn.py
@ -869,6 +878,17 @@ site-intrants: ## Les intrants que le SITE monte expose a ses locataires (derive
site-intrants-verifier: ## Le locataire monte suit-il encore son site ? (aucune ecriture) site-intrants-verifier: ## Le locataire monte suit-il encore son site ? (aucune ecriture)
python3 scripts/site_intrants.py --verifier python3 scripts/site_intrants.py --verifier
face-reseau-publier: ## Chaque locataire publie sa face reseau pour son site (a commiter chez lui, apres `make flux`)
@# LE LOCATAIRE PUBLIE, LE SITE LIT (2026-10-05). Le site lisait les fichiers internes de ses
@# locataires ; il lit desormais `face-reseau.yml`. P92 refuse une face perimee.
python3 scripts/contexte.py --publier-faces
fiches-site-deposer: ## Le site depose chez chacun de ses locataires la fiche qu'il lui destine (a commiter chez eux)
@# LE SITE DEPOSE, LE LOCATAIRE LIT (2026-10-05). Le runner d'un locataire n'a pas le depot
@# de son site : ce que le site lui attribue et lui offre lui parvient par ce fichier,
@# `fiche-site.yml`, que l'instancier lit. P91 refuse une fiche perimee.
python3 scripts/contexte.py --deposer-fiches
.PHONY: vpn-admin-plan vpn-admin-appliquer .PHONY: vpn-admin-plan vpn-admin-appliquer
vpn-admin-plan: ## Acces WireGuard des admins : ce que la frontiere porte face au plan (aucune ecriture) vpn-admin-plan: ## Acces WireGuard des admins : ce que la frontiere porte face au plan (aucune ecriture)
python3 scripts/vpn_admin.py plan python3 scripts/vpn_admin.py plan
@ -918,7 +938,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 valider: ansible-runtime ## Passe la recette de validation sur la flotte
ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/valider.yml ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/valider.yml
.PHONY: restauration-etat restauration-renoncer sauvegarder-maintenant .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 sauvegarder-maintenant: ansible-runtime ## Depose l'etat de chaque noeud TOUT DE SUITE (avant de raser) — HOTE=<hote> optionnel
@set -e; $(VAULT_UNE_FOIS) \ @set -e; $(VAULT_UNE_FOIS) \
ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/backup/restauration.yml \ ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/backup/restauration.yml \
@ -932,6 +952,15 @@ restauration-etat: ansible-runtime ## Ce qui attend dans le depot, et ce que cha
ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/backup/restauration.yml \ ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/backup/restauration.yml \
$(if $(HOTE),--limit "$(HOTE)") $(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 restauration-renoncer: ansible-runtime ## Ecarte l'etat anterieur d'un jeu, sans le remettre — HOTE= JEU= CONFIRMER=true
@set -e; \ @set -e; \
if [[ "$(CONFIRMER)" != "true" || -z "$(HOTE)" || -z "$(JEU)" ]]; then \ if [[ "$(CONFIRMER)" != "true" || -z "$(HOTE)" || -z "$(JEU)" ]]; then \
@ -1126,14 +1155,23 @@ define VAULT_UNE_FOIS
endef endef
flotte-creer: _instance-requise ## Cree les VM manquantes de la flotte depuis le plan — PARALLELE=n # LE SITE MATERIALISE UN LOCATAIRE NOMME, D'APRES SA FACE (2026-10-05, docs/conception-contextes.md,
# materialisation M2). Avec `TENANT=<depot>`, la liste des machines et leurs parametres de clonage
# viennent de `face-reseau.yml` que le locataire publie — rendus a l'octet pres comme
# `parametres-proxmox` (P93) — et aucune instance n'est montee. Sans `TENANT`, l'ancienne lecture
# de l'instance montee (le poste, le laboratoire).
LISTE_ACTIFS = $(if $(TENANT),python3 scripts/contexte.py --hotes-actifs $(TENANT),python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) lister-actifs)
PARAMS_CLONAGE = $(if $(TENANT),python3 scripts/contexte.py --parametres-clonage $(TENANT) --hote,python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) parametres-proxmox --hote)
INSTANCE_OU_LOCATAIRE = $(if $(TENANT),,_instance-requise)
flotte-creer: $(INSTANCE_OU_LOCATAIRE) ## Cree les VM manquantes de la flotte depuis le plan — PARALLELE=n
@set -e; \ @set -e; \
if [[ "$(CONFIRMER)" != "true" ]]; then \ if [[ "$(CONFIRMER)" != "true" ]]; then \
printf '%s\n' 'Refus: creation de TOUTES les VM actives du plan (clone Proxmox).'; \ printf '%s\n' 'Refus: creation de TOUTES les VM actives du plan (clone Proxmox).'; \
printf '%s\n' 'Relancer avec CONFIRMER=true.'; \ printf '%s\n' 'Relancer avec CONFIRMER=true.'; \
exit 2; \ exit 2; \
fi; \ fi; \
hotes="$$(python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) lister-actifs)"; \ hotes="$$($(LISTE_ACTIFS))"; \
if [[ -z "$$hotes" ]]; then printf '%s\n' 'Refus: aucun hote actif dans le plan.'; exit 2; fi; \ if [[ -z "$$hotes" ]]; then printf '%s\n' 'Refus: aucun hote actif dans le plan.'; exit 2; fi; \
par="$(PARALLELE)"; par="$${par:-4}"; \ par="$(PARALLELE)"; par="$${par:-4}"; \
tmp="$$(mktemp -d)"; trap 'rm -rf "$$tmp"' EXIT; \ tmp="$$(mktemp -d)"; trap 'rm -rf "$$tmp"' EXIT; \
@ -1142,7 +1180,7 @@ flotte-creer: _instance-requise ## Cree les VM manquantes de la flotte depuis le
n=0; \ n=0; \
for h in $$hotes; do \ for h in $$hotes; do \
printf '\n=== Creation VM: %s ===\n' "$$h"; \ printf '\n=== Creation VM: %s ===\n' "$$h"; \
( $(MAKE) creer-vm HOTE="$$h" > "$$tmp/$$h.log" 2>&1; printf '%s' "$$?" > "$$tmp/$$h.rc" ) & \ ( $(MAKE) creer-vm HOTE="$$h" $(if $(TENANT),TENANT="$(TENANT)") > "$$tmp/$$h.log" 2>&1; printf '%s' "$$?" > "$$tmp/$$h.rc" ) & \
n=$$((n+1)); \ n=$$((n+1)); \
if (( n >= par )); then wait -n || true; n=$$((n-1)); fi; \ if (( n >= par )); then wait -n || true; n=$$((n-1)); fi; \
done; \ done; \
@ -1157,6 +1195,13 @@ flotte-creer: _instance-requise ## Cree les VM manquantes de la flotte depuis le
if (( echec )); then printf '\nAu moins une VM n a pas ete creee — rien ne continue.\n'; exit 2; fi; \ if (( echec )); then printf '\nAu moins une VM n a pas ete creee — rien ne continue.\n'; exit 2; fi; \
printf '\nToutes les VM actives sont creees.\n' printf '\nToutes les VM actives sont creees.\n'
locataire-creer: ## Le SITE cree les VM d'un locataire d'apres sa face reseau — TENANT=<depot> CONFIRMER=true [PARALLELE=n]
@if [[ -z "$(TENANT)" ]]; then printf '%s\n' 'Refus: TENANT=<depot du locataire> requis.'; exit 2; fi
@$(MAKE) --no-print-directory flotte-creer TENANT="$(TENANT)" CONFIRMER="$(CONFIRMER)" PARALLELE="$(PARALLELE)"
locataire-raser: ## DESTRUCTIF : le SITE rase les VM d'un locataire d'apres sa face reseau — TENANT=<depot> CONFIRMER=true INSTANCE=<nom court> [HOTE=h]
@if [[ -z "$(TENANT)" ]]; then printf '%s\n' 'Refus: TENANT=<depot du locataire> requis.'; exit 2; fi
@$(MAKE) --no-print-directory raser TENANT="$(TENANT)" CONFIRMER="$(CONFIRMER)" INSTANCE="$(INSTANCE)" HOTE="$(HOTE)"
# --- LES MACHINES DU SITE ----------------------------------------------------- # --- LES MACHINES DU SITE -----------------------------------------------------
# #
# UN SITE N'EST PAS UN PLAN. Un tenant se derive de son `index` ; un site n'en a pas et # UN SITE N'EST PAS UN PLAN. Un tenant se derive de son `index` ; un site n'en a pas et
@ -1422,8 +1467,8 @@ _attendre-flotte: ansible-runtime
printf 'Flotte joignable.\n' printf 'Flotte joignable.\n'
.PHONY: reconstruire .PHONY: reconstruire
raser: ansible-runtime _instance-requise ## DESTRUCTIF : detruit les VM derivees du plan — exige CONFIRMER=true ET INSTANCE=<nom> raser: ansible-runtime $(INSTANCE_OU_LOCATAIRE) ## DESTRUCTIF : detruit les VM derivees du plan — exige CONFIRMER=true ET INSTANCE=<nom> [TENANT=<depot>]
@python3 scripts/raser.py $(if $(INSTANCE),--instance $(INSTANCE)) $(if $(HOTE),--hote $(HOTE)) $(if $(filter true,$(CONFIRMER)),--confirmer) @python3 scripts/raser.py $(if $(TENANT),--locataire $(TENANT)) $(if $(INSTANCE),--instance $(INSTANCE)) $(if $(HOTE),--hote $(HOTE)) $(if $(filter true,$(CONFIRMER)),--confirmer)
# `make flux` D'ABORD, et ce n'est pas une precaution : sans lui, `instance/flux-genere/` # `make flux` D'ABORD, et ce n'est pas une precaution : sans lui, `instance/flux-genere/`
# est VIDE et le socle deploie nftables en `policy drop` SANS AUCUNE REGLE DERIVEE. La # est VIDE et le socle deploie nftables en `policy drop` SANS AUCUNE REGLE DERIVEE. La
@ -1605,7 +1650,7 @@ cloner-vm: ansible-runtime ## Clone une VM depuis le gabarit dore — HOTE=<nom>
-e proxmox_clone_nom="$(HOTE)" \ -e proxmox_clone_nom="$(HOTE)" \
-e proxmox_clone_vmid="$(VMID)" \ -e proxmox_clone_vmid="$(VMID)" \
-e proxmox_clone_ipconfig0="$$ipconfig" \ -e proxmox_clone_ipconfig0="$$ipconfig" \
-e proxmox_clone_pool="$(if $(POOL),$(POOL),$$(python3 scripts/devis_proxmox_pools.py --pool-actif))" \ -e proxmox_clone_pool="$(if $(POOL),$(POOL),$$(python3 scripts/devis_proxmox_pools.py $(if $(TENANT),--pool-du-locataire $(TENANT),--pool-actif)))" \
); \ ); \
[[ -n "$(VMID_MODELE)" ]] && extra_vars+=( -e proxmox_clone_vmid_modele="$(VMID_MODELE)" ); \ [[ -n "$(VMID_MODELE)" ]] && extra_vars+=( -e proxmox_clone_vmid_modele="$(VMID_MODELE)" ); \
[[ -n "$(NOEUD_PROXMOX)" ]] && extra_vars+=( -e proxmox_clone_noeud="$(NOEUD_PROXMOX)" ); \ [[ -n "$(NOEUD_PROXMOX)" ]] && extra_vars+=( -e proxmox_clone_noeud="$(NOEUD_PROXMOX)" ); \
@ -1648,13 +1693,13 @@ cloner-vm: ansible-runtime ## Clone une VM depuis le gabarit dore — HOTE=<nom>
fi; \ fi; \
ansible-playbook -i localhost, $(PLAYBOOK_PROXMOX_CLONER_VM) "$${vault_args[@]}" "$${extra_vars[@]}" ansible-playbook -i localhost, $(PLAYBOOK_PROXMOX_CLONER_VM) "$${vault_args[@]}" "$${extra_vars[@]}"
creer-vm: _instance-requise ## Cree une VM et attend qu'elle soit joignable — HOTE=<nom> creer-vm: $(INSTANCE_OU_LOCATAIRE) ## Cree une VM et attend qu'elle soit joignable — HOTE=<nom> [TENANT=<depot>]
@set -e; \ @set -e; \
if [[ -z "$(HOTE)" ]]; then \ if [[ -z "$(HOTE)" ]]; then \
printf '%s\n' 'Refus: relancer avec HOTE=nom_hote (declare dans le plan).'; \ printf '%s\n' 'Refus: relancer avec HOTE=nom_hote (declare dans le plan).'; \
exit 2; \ exit 2; \
fi; \ fi; \
params="$$(python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) parametres-proxmox --hote $(HOTE))"; \ params="$$($(PARAMS_CLONAGE) $(HOTE))"; \
eval "$$params"; \ eval "$$params"; \
export SETOPS_CLES_AMORCAGE; \ export SETOPS_CLES_AMORCAGE; \
$(MAKE) cloner-vm \ $(MAKE) cloner-vm \
@ -1695,7 +1740,7 @@ creer-vm: _instance-requise ## Cree une VM et attend qu'elle soit joignable —
@# ATTENDRE LA DISPONIBILITE est un autre geste, deplace chez `deployer` : cloud-init @# ATTENDRE LA DISPONIBILITE est un autre geste, deplace chez `deployer` : cloud-init
@# et dpkg se guettent juste avant de configurer, par celui qui va configurer. @# et dpkg se guettent juste avant de configurer, par celui qui va configurer.
@if [[ "$(ATTENDRE)" != "false" ]]; then \ @if [[ "$(ATTENDRE)" != "false" ]]; then \
params="$$(python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) parametres-proxmox --hote $(HOTE))"; \ params="$$($(PARAMS_CLONAGE) $(HOTE))"; \
eval "$$params"; \ eval "$$params"; \
python3 scripts/attendre_materialisation.py \ python3 scripts/attendre_materialisation.py \
--vmid "$$SETOPS_VMID" --ip "$$SETOPS_IP" --hote "$(HOTE)" \ --vmid "$$SETOPS_VMID" --ip "$$SETOPS_IP" --hote "$(HOTE)" \

View file

@ -0,0 +1,130 @@
# Preuve de conformite — Set-OPS — 2026-10-09
> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer
> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;
> aucune validation n'est reimplementee ici. Voir le mode d'emploi :
> [`docs/audit/README.md`](README.md), et le registre trace :
> [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml`
- **Verdict** : ✅ CONFORME (94 OK · 0 echec · 0 saute)
## Preuves
| # | Preuve | Affirmations | Statut | Detail |
|---|---|---|---|---|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | } \| to_nice_json }}`. |
| P02 | Tests unitaires (inventaire, raser, ecritures du plan, rendu du GUI) | — | ✅ OK | OK |
| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 3 instance(s) verifiee(s) — OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre : plan et inventaire applique coincident. |
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check), 1 nom(s) surveille(s) sans reference orpheline. |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 42 groupes classes, aucun cycle, aucune arete en arriere ; playbooks/site.yml a jour. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 40 rôles, 119 flux, schéma + matrice OK. |
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_resolveur_site |
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 13 hotes, 33 groupes (inventaire dechiffre et parse). |
| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. |
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 35 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) |
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 30 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) et 7 zone(s) de site : adressage 100% derive du seed index. |
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 4 instance(s) federee(s), aucun index en collision. |
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). |
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 14 reseau(x), aucune collision avec la plage tenant. |
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | note : serveur_powerdns declare un port `derive` que le plan du site ne resout pas — aucune regle emise. |
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 2 tenant(s), 40 groupe(s), 108 regle(s). |
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 13 hote(s) x 6 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). |
| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. |
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 35 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 34 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 23, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 2 zone(s), 12 VNet(s), 12 sous-reseau(x), aucune collision. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 78 scripts expliques et atteignables, 151 cibles make documentees, 69 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (148 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 44 revendication(s) de port, aucune collision entre roles co-localises (35 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 50 document(s) declarent leur lecteur (47 genere(s) exempte(s)). |
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 4 application(s) exigeant une base l'ont toutes (4 entree(s) au registre). |
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 9 hote(s) de l'ecosysteme et 3 du site detiennent de l'etat, tous porteurs de `client_backup` (11 groupe(s) au catalogue). |
| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. |
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 42 role(s) serveur/client tous nommes, 42 groupe(s) cite(s) en table existent tous. |
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 97 page(s) de wiki toutes atteignables. |
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 74 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 3 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal). |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 9 machine(s) du plan retrouvees, 201 regle(s) du site. |
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 integration(s) appliquent leur serveur avant leurs clients. |
| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). |
| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos |
| P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). |
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 102 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. |
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (137 lignes). |
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 304 regles `pass`), tous non consignes et tous motives. |
| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 |
| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. |
| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 13 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere : WAN muet, interfa |
| P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 9 role(s) applique(s), aucun secret de tenant reclame. |
| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 13 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 13. |
| P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. |
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (94 preuves, 69 roles, 42 groupes). |
| P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. |
| P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). |
| P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `e9215a2` (publie le 2026-10-07). |
| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 56 champ(s) sur 7 registres ; il couvre tout ce que les plans reels contiennent, et la FORME de chaque champ (scalaire / objet / table) corresp |
| P62 | Schema du plan : il decrit tout ce que le MOTEUR accepte | AFF-033 | ✅ OK | Les 4 validateurs n'acceptent aucun champ que le schema ignore (applications:8, bases_donnees:4, domaines_publics:9, serveurs:3 champ(s) lus par validateur). |
| P63 | cloud-init nait avec la VM et ne lui survit pas | — | ✅ OK | cloud-init est au gabarit (la premiere seconde), absent du socle (pas de va-et-vient), et retire par le durcissement — avec la garde qui verifie que le reseau s |
| P64 | Sondes de supervision : declarees ET deposees | — | ✅ OK | 47 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_journal/journaux, client_metrique/metriques, client_pki/certificat, client_s |
| P65 | Depots tiers : demandes au cache, jamais en HTTPS direct | — | ✅ OK | 4 depot(s) tiers relaye(s) par le cache, aucun role ne les vise en https:// ecrit en dur. |
| P66 | Clients OIDC : chaque URI vise un nom que le plan expose | — | ✅ OK | 4 client(s) OIDC, toutes leurs URI visent un FQDN que le plan expose (6 exposition(s)). |
| P67 | Nom public : le service porte celui du plan, pas celui du role | — | ✅ OK | 13 service(s) expose(s) portent le nom du plan, sur 2 inventaire(s) : instance, SITE. |
| P68 | Cle de depot telechargee : mesuree avant d'etre utilisee | — | ✅ OK | 5 role(s) telechargent une cle de depot, tous la mesurent avant de s'en servir. |
| P69 | Amorcage d'un tenant : l'adresse designe le site REEL | — | ✅ OK | 2 adresse(s) d'amorcage designent bien une machine du site. |
| P70 | Depot de binaires : il tient tout ce que les roles vont chercher | — | ✅ OK | 27 artefact(s) direct(s) tenus par le depot du site. |
| P71 | Pool du site : le genome ne nait pas chez un tenant | — | ✅ OK | `site-creer` nomme `--pool-site` ; le pool du genome ne peut plus etre celui d'un tenant. |
| P72 | Annuaire : aucun service ne se lie avec le compte du maitre | — | ✅ OK | 5 role(s) consultent l'annuaire, chacun avec SON compte de service ; seul `amorcage_acces` garde celui d'administration, et il provisionne au lieu de consommer. |
| P73 | Le locataire designe les services de son site REEL | — | ✅ OK | CONFORME : 10 intrant(s) du locataire concordent avec ce que le site expose (1 non declare(s), donc derive(s) ou non utilise(s)). |
| P74 | Gabarit dore : une seule declaration, au plan du site | — | ✅ OK | Gabarit declare une seule fois : VMID 9006 « modeleSetOPS-minimal ». |
| P75 | Les parametres de clonage traversent les trois maillons | — | ✅ OK | 14 parametre(s) de clonage, tous emis par l'inventaire. |
| P76 | Tout gabarit de role se rend vraiment | — | ✅ OK | 189 gabarits de role : tous se rendent. |
| P77 | Panneaux declares : assemblables, et gradues | — | ✅ OK | 8 panneau(x) declare(s) dans 2 role(s), tous avec titre, expression, raison et une unite que la table sait traduire. |
| P78 | Un consommateur de base suit le verrou TLS de son serveur | — | ✅ OK | 3 consommateur(s) suivent la posture de leur serveur ; 2 sans reglage TLS (serveur_icingaweb2, serveur_nextcloud). |
| P79 | Replis silencieux : une derivation vide ne passe pas pour un succes | — | ✅ OK | 4 ecosysteme(s) (OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, SITE-Chezlepro) : pattes, edges, certificats, rechargements, jumeaux d'amorcage, sorties du |
| P80 | Remise au client : inscrite, nommee, et son second temps a l'heure | — | ✅ OK | Aucun ecosysteme remis a un client : rien a tenir. |
| P81 | La console dit sa portee, et ne sert pas un inventaire vide en silence | — | ✅ OK | Portee `poste` derivee des voutes portees, source d'inventaire `instance/inventories/principal/hosts.yml`, et 15 route(s) POST exigent toutes un pouvoir. |
| P82 | DNS public : les zones publiees sont servies, signees avant d'etre exposees | — | ✅ OK | 2 zone(s) publique(s) declaree(s), toutes servies par le site ; 2 replication(s) declaree(s) des deux cotes ; transfert ouvert a la cle seule ; aucune expositio |
| P83 | Assistants : le registre des runbooks ne prend pas de retard sur le Makefile | — | ✅ OK | 17 runbooks, 144 etapes, 150 cibles documentees : chacune portee par un assistant ou exemptee avec son motif. |
| P84 | La fiche du site dit exactement ce que chaque locataire porte | — | ✅ OK | 2 fiche(s) de site : chacune dit exactement ce que son locataire porte (copies, racine, inventaire genere). |
| P85 | La face reseau du locataire dit exactement ce que le site en tire | — | ✅ OK | 2 face(s) reseau : chacune dit exactement ce que le site en tire (decouverte, devis Proxmox, frontiere, DNS public, sauvegarde). |
| P86 | Machine par machine, le locataire et Proxmox admettent les memes entrees | — | ✅ OK | 2 locataire(s) : machine par machine et port par port, nftables et Proxmox admettent les memes sources. |
| P87 | La frontiere designe chaque locataire par les identites qu'il publie | — | ✅ OK | 2 locataire(s) : la frontiere les designe par les identites qu'ils publient (supernet, administration, tunnel, groupes, routes, sortie). |
| P88 | Ce qui entre depuis l'Internet chez un locataire, la frontiere le tient de lui | — | ✅ OK | 2 locataire(s) : regles WAN, redirections et tunnel disent exactement ce que leur face reseau ouvre a tous. |
| P89 | Ce que l'administration atteint chez un locataire, la frontiere le tient de lui | — | ✅ OK | 2 locataire(s) : par la gestion, le VPN et le tunnel, l'administration atteint exactement ce que leur face reseau lui ouvre. |
| P90 | Ce qui sort d'un locataire vers l'Internet, la frontiere le tient de lui | — | ✅ OK | 2 locataire(s) : la frontiere laisse sortir exactement ce que leur face reseau publie. |
| P91 | La fiche du site est deposee a jour, et l'inventaire se genere sans le site | — | ✅ OK | 2 locataire(s) : fiche du site deposee a jour, et inventaire genere sans le site identique a l'octet pres. |
| P92 | Chaque locataire a publie sa face reseau a jour | — | ✅ OK | 2 locataire(s) : face reseau publiee, a jour. |
| P93 | La face publie, machine par machine, les parametres de clonage de l'inventaire | — | ✅ OK | 26 machine(s) : la face publie exactement les parametres de clonage de l'inventaire. |
| P94 | Creer, raser et placer un locataire par sa face visent les memes machines | — | ✅ OK | 2 locataire(s) : creer, raser et placer visent par la face les memes machines que par l'instance montee. |
## Couverture des affirmations ✅ du registre
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
elles restent hors du harnais recurrent (rien d'executable a rejouer).
## Declarations d'intention (⚪ invérifiables localement — assumees)
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
comme declarations d'intention, non comme preuves :
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
contre une flotte vivante.
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
_Rapport genere le 2026-10-09._

View file

@ -1,5 +1,5 @@
--- ---
# Ecrit par `make wiki-publier`, lu par la preuve P60. Ne pas editer a la main. # Ecrit par `make wiki-publier`, lu par la preuve P60. Ne pas editer a la main.
remote: ssh://git@eregion.chezlepro.ca:2222/Alliance-Boreale/Set-OPS-Public.wiki.git remote: ssh://git@eregion.chezlepro.ca:2222/Alliance-Boreale/Set-OPS-Public.wiki.git
source: 3fe7e3c source: e9215a2
date: 2026-09-29 date: 2026-10-07

View file

@ -25,8 +25,8 @@ README de rôles). Cette page comble ces deux trous.
|---|---|---| |---|---|---|
| rôles | 69 | `roles/*/` | | rôles | 69 | `roles/*/` |
| README de rôles | 69 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette | | README de rôles | 69 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
| documents | 45 | `docs/*.md` | | documents | 46 | `docs/*.md` |
| pièces d'audit | 51 | `docs/audit/*` | | pièces d'audit | 52 | `docs/audit/*` |
| unités de wiki | 27 | `wiki/*.md` | | unités de wiki | 27 | `wiki/*.md` |
| décisions en vigueur | 85 | lignes `\| **D-nn** \|` de `decisions-architecture.md` | | décisions en vigueur | 85 | lignes `\| **D-nn** \|` de `decisions-architecture.md` |
| décisions renversées | 3 | lignes `\| **D-nn** —` du même document | | décisions renversées | 3 | lignes `\| **D-nn** —` du même document |
@ -36,7 +36,7 @@ README de rôles). Cette page comble ces deux trous.
| Sujet | Documents | | Sujet | Documents |
|---|---| |---|---|
| **Autorité / gouvernance** | `AGENTS.md` (source d'autorité), `CLAUDE.md`, `docs/MISE-A-JOUR-CODEX-CLAUDE.md` | | **Autorité / gouvernance** | `AGENTS.md` (source d'autorité), `CLAUDE.md`, `docs/MISE-A-JOUR-CODEX-CLAUDE.md` |
| **Le modèle (plan)** | `docs/architecture-set-ops.md` (survol) → `docs/plan-et-generation.md` (à fond) → `docs/meta-classe.md` (concept) | | **Le modèle (plan)** | `docs/architecture-set-ops.md` (survol) → `docs/plan-et-generation.md` (à fond) → `docs/meta-classe.md` (concept) → `docs/conception-contextes.md` (site et locataire : deux classes, un contrat) |
| **Services, maturité, dette** | `docs/catalogue-services.md` (**la carte de maturité + la cruft y sont déjà**) | | **Services, maturité, dette** | `docs/catalogue-services.md` (**la carte de maturité + la cruft y sont déjà**) |
| **Exploitation / VM** | `docs/vm-lifecycle.md`, `docs/procedure-template-debian13-proxmox.md`, `docs/config-proxmox.md`, `docs/nomenclature-vm.md`, `docs/multi-instances.md` | | **Exploitation / VM** | `docs/vm-lifecycle.md`, `docs/procedure-template-debian13-proxmox.md`, `docs/config-proxmox.md`, `docs/nomenclature-vm.md`, `docs/multi-instances.md` |
| **Conceptions de domaine** | `docs/identite-sso.md`, `docs/courriel-conception.md`, `docs/bindings-conception.md`, `docs/dns-interne.md`, `docs/dimensionnement-ressources.md`, `docs/integrations-vm.md` | | **Conceptions de domaine** | `docs/identite-sso.md`, `docs/courriel-conception.md`, `docs/bindings-conception.md`, `docs/dns-interne.md`, `docs/dimensionnement-ressources.md`, `docs/integrations-vm.md` |

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

View file

@ -30,7 +30,7 @@ make placement-plan # chaque VM est-elle là où le plan la met
## Le trou qu'il comble ## Le trou qu'il comble
`scripts/prouver.py` porte 83 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le `scripts/prouver.py` porte 94 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le
dépôt. Zéro appel réseau, zéro SSH, zéro `ansible`. Elles établissent que le dépôt est dépôt. Zéro appel réseau, zéro SSH, zéro `ansible`. Elles établissent que le dépôt est
cohérent **avec lui-même** — que les handlers existent, que les intrants ont un cohérent **avec lui-même** — que les handlers existent, que les intrants ont un
propriétaire, que rien n'est codé en dur. propriétaire, que rien n'est codé en dur.

View file

@ -66,6 +66,31 @@ Un **résolveur** (miroir de `instancier`) agrège, par serveur, les `flux.yml`
- **le registre d'audit** : `docs/registre-flux.md` (généré) + une cible `make flux`. - **le registre d'audit** : `docs/registre-flux.md` (généré) + une cible `make flux`.
## Activation prudente ## Activation prudente
### Descriptions conservées par les pare-feux
La `raison` non vide du flux et son rôle alimentent les champs natifs : `comment`
pour nftables et Proxmox, `description` pour le filtrage OPNsense (`descr` pour ses
redirections). Un commentaire de fichier `# ...` ne survit pas au chargement nftables.
Les règles communes (administration, connexions établies, refus) portent aussi leur motif.
Les libellés sont sur une ligne. Le commentaire nftables est borné à 127 octets UTF-8 ;
OPNsense garde sa clé `setops:...` complète et borne le texte qui suit pour rester dans
255 octets. Une raison abrégée reste disponible en entier dans le registre des flux.
Le commentaire Proxmox conserve aussi le pair nommé dans le devis.
Une correction de description apparaît dans `make proxmox-fw-plan` et
`make frontiere-plan`. Son application met à jour le champ sur la règle existante,
puis le relit : elle ne recrée pas la règle et ne modifie ni son activation ni sa
politique. Les confirmations habituelles restent requises pour appliquer ces devis.
`make test` vérifie notamment cette convergence et les limites des champs.
Références des champs natifs : [nftables](https://netfilter.org/projects/nftables/manpage.html),
[API Proxmox](https://github.com/proxmox/pve-firewall/blob/master/src/PVE/API2/Firewall/Rules.pm),
[descriptions OPNsense](https://github.com/opnsense/core/blob/master/src/opnsense/mvc/app/models/OPNsense/Base/FieldTypes/DescriptionField.php).
### Activer le filtrage
Activer nftables = **action destructive** (peut couper l'accès) → confirmation explicite + Activer nftables = **action destructive** (peut couper l'accès) → confirmation explicite +
déploiement graduel (garder l'accès SSH/Ansible, tester par nœud). déploiement graduel (garder l'accès SSH/Ansible, tester par nœud).
@ -130,4 +155,3 @@ ignorée, ce qui est pire que de ne pas l'avoir.
**Corollaire à retenir** : un port qu'on **subit** (le défaut amont d'un logiciel) doit être **Corollaire à retenir** : un port qu'on **subit** (le défaut amont d'un logiciel) doit être
imposé et déclaré comme les autres. Celui d'Alloy ne l'était pas, et c'est la seule raison imposé et déclaré comme les autres. Celui d'Alloy ne l'était pas, et c'est la seule raison
pour laquelle la collision a pu durer des semaines. pour laquelle la collision a pu durer des semaines.

View file

@ -430,3 +430,12 @@ Reste à trancher sur une interface **physique** : `ip ?`, `access-group ?`, `sp
déclare 53 en UDP et TCP, la récursion depuis la racine que le choix souverain implique. déclare 53 en UDP et TCP, la récursion depuis la racine que le choix souverain implique.
Le `block out` de la section 5 est donc un vrai default-deny **assumé**, et le devis Le `block out` de la section 5 est donc un vrai default-deny **assumé**, et le devis
l'énonce désormais au lieu de le poser en silence. l'énonce désormais au lieu de le poser en silence.
- **La sortie générale n'existe plus — 2026-10-10.** Le 123 était déjà passé à la frontière
(2026-09-11). Le 443 et le 80 du socle sont devenus conditionnels (`seulement_si:
artefacts_amorcage` vide) : le site et ses locataires prennent apt par le cache du site,
et ne les reçoivent plus. Mesurée à la frontière, cette sortie ne servait plus qu'à ce
qu'aucun rôle ne déclarait : télémétrie de Grafana, d'Alloy et de Loki et vérification
de version de Collabora (coupées à la source), cartes de rspamd et repère du génome
(déclarés dans `serveur_rspamd` et `serveur_ops_site`). Chaque sortie vers Internet porte
désormais le nom du rôle qui en a besoin. `test_conditions_site.py` refuse qu'un rôle
universel en rouvre une sans condition.

View file

@ -23,7 +23,7 @@
| `serveur_backup_site` | ingress | 22 | tcp | voisins_site | ssh | Les écosystèmes de ce site déposent leur état ici tant qu'ils n'ont pas leur propre dépôt. SFTP par un utilisateur restreint, jamais un compte d'administration : le site reçoit des octets chiffrés par restic, il ne peut pas les lire. | | `serveur_backup_site` | ingress | 22 | tcp | voisins_site | ssh | Les écosystèmes de ce site déposent leur état ici tant qu'ils n'ont pas leur propre dépôt. SFTP par un utilisateur restreint, jamais un compte d'administration : le site reçoit des octets chiffrés par restic, il ne peut pas les lire. |
| `serveur_cache_site` | ingress | 3142 | tcp | voisins_site | clair | Servir les caches des écosystèmes voisins. Debian n'est ainsi téléchargé qu'une fois pour tout le site, et le cache ne voit que des requêtes AGRÉGÉES — jamais quelle machine installe quoi. | | `serveur_cache_site` | ingress | 3142 | tcp | voisins_site | clair | Servir les caches des écosystèmes voisins. Debian n'est ainsi téléchargé qu'une fois pour tout le site, et le cache ne voit que des requêtes AGRÉGÉES — jamais quelle machine installe quoi. |
| `serveur_cache_site` | egress | 80 | tcp | externe | clair | Remplir le cache depuis les dépôts Debian amont. En clair parce que les dépôts apt sont signés : l'intégrité vient de la signature, pas du transport. | | `serveur_cache_site` | egress | 80 | tcp | externe | clair | Remplir le cache depuis les dépôts Debian amont. En clair parce que les dépôts apt sont signés : l'intégrité vient de la signature, pas du transport. |
| `serveur_cache_site` | egress | 443 | tcp | externe | tls-requis | Les dépôts tiers qui n'existent qu'en HTTPS (smallstep, Grafana, Icinga). Le cache les relaie pour que la flotte n'ait pas à sortir elle-même. | | `serveur_cache_site` | egress | 443 | tcp | externe | tls-requis | Les dépôts tiers qui n'existent qu'en HTTPS (smallstep, Grafana, Icinga), et le dépôt de binaires (roues PyPI, collections Galaxy, versions GitHub, Nextcloud, Forgejo). Le site les relaie pour que la flotte n'ait pas à sortir elle-même. |
| `serveur_collabora` | ingress | 9980 | tcp | edge | clair | Éditeur servi au navigateur via l'edge (WebSocket WOPI ; TLS terminé à l'edge). | | `serveur_collabora` | ingress | 9980 | tcp | edge | clair | Éditeur servi au navigateur via l'edge (WebSocket WOPI ; TLS terminé à l'edge). |
| `serveur_collabora` | ingress | 9980 | tcp | localhost | clair | Vérifications WOPI serveur→Collabora depuis Nextcloud co-localisé. | | `serveur_collabora` | ingress | 9980 | tcp | localhost | clair | Vérifications WOPI serveur→Collabora depuis Nextcloud co-localisé. |
| `serveur_debian` | ingress | 22 | tcp | flotte, externe | ssh | Plan de gestion : administration et déploiement Ansible par SSH (inter-nœud ; l'accès depuis l'extérieur est filtré à l'OPNsense). | | `serveur_debian` | ingress | 22 | tcp | flotte, externe | ssh | Plan de gestion : administration et déploiement Ansible par SSH (inter-nœud ; l'accès depuis l'extérieur est filtré à l'OPNsense). |
@ -31,9 +31,9 @@
| `serveur_debian` | ingress | frag-needed | icmp | externe | n-a | ICMP « fragmentation nécessaire » entrant : sans lui, un distant ne peut pas nous demander de réduire nos paquets — les transferts se figent. | | `serveur_debian` | ingress | frag-needed | icmp | externe | n-a | ICMP « fragmentation nécessaire » entrant : sans lui, un distant ne peut pas nous demander de réduire nos paquets — les transferts se figent. |
| `serveur_debian` | egress | 53 | udp | frontiere | n-a | Résolution de noms à l'amorçage, avant que le résolveur de l'écosystème n'existe. Sans elle, les machines d'un site neuf ne peuvent pas résoudre leurs dépôts de paquets — et rien ne peut donc s'installer, y compris le résolveur lui-même. | | `serveur_debian` | egress | 53 | udp | frontiere | n-a | Résolution de noms à l'amorçage, avant que le résolveur de l'écosystème n'existe. Sans elle, les machines d'un site neuf ne peuvent pas résoudre leurs dépôts de paquets — et rien ne peut donc s'installer, y compris le résolveur lui-même. |
| `serveur_debian` | egress | 53 | tcp | frontiere | n-a | Réponses longues et bascule TCP, obligatoires en DNS. Déclarer l'UDP sans le TCP donne une résolution qui marche jusqu'à la première réponse tronquée. | | `serveur_debian` | egress | 53 | tcp | frontiere | n-a | Réponses longues et bascule TCP, obligatoires en DNS. Déclarer l'UDP sans le TCP donne une résolution qui marche jusqu'à la première réponse tronquée. |
| `serveur_debian` | egress | 80 | tcp | externe | clair | Dépôts apt en clair et redirections HTTP des miroirs (l'intégrité vient de la signature des paquets, pas du transport). | | `serveur_debian` | egress | 80 | tcp | externe | clair | Dépôts apt en clair et redirections des miroirs, pour un écosystème SANS amont d'artefacts. L'intégrité vient de la signature des paquets, pas du transport. |
| `serveur_debian` | egress | 123 | udp | frontiere | n-a | Synchronisation d'horloge (NTP) contre la frontière, autorité de temps de l'écosystème. Une dérive fait échouer la validation des certificats step-ca et l'authentification SSO. | | `serveur_debian` | egress | 123 | udp | frontiere | n-a | Synchronisation d'horloge (NTP) contre la frontière, autorité de temps de l'écosystème. Une dérive fait échouer la validation des certificats step-ca et l'authentification SSO. |
| `serveur_debian` | egress | 443 | tcp | externe | tls-requis | Dépôts apt en HTTPS (Debian, Smallstep, Grafana, Icinga, Forgejo, Nextcloud) — sans quoi aucun correctif de sécurité n'entre. | | `serveur_debian` | egress | 443 | tcp | externe | tls-requis | Dépôts apt en direct, pour un écosystème SANS aucune source d'artefacts. Le site et ses locataires passent par le cache du site et ne la reçoivent pas. |
| `serveur_debian` | egress | frag-needed | icmp | externe | n-a | ICMP « fragmentation nécessaire » sortant : c'est ainsi que nos hôtes signalent l'overlay à 1450 aux correspondants distants. | | `serveur_debian` | egress | frag-needed | icmp | externe | n-a | ICMP « fragmentation nécessaire » sortant : c'est ainsi que nos hôtes signalent l'overlay à 1450 aux correspondants distants. |
| `serveur_dns_public` | ingress | 53 | udp | voisins_site | clair | NOTIFY des primaires des locataires du site : une zone publique a change. Le contenu d'une zone publique n'a rien de secret ; ce qui compte est QUI peut le modifier, et c'est TSIG qui le garde, sur le transfert. | | `serveur_dns_public` | ingress | 53 | udp | voisins_site | clair | NOTIFY des primaires des locataires du site : une zone publique a change. Le contenu d'une zone publique n'a rien de secret ; ce qui compte est QUI peut le modifier, et c'est TSIG qui le garde, sur le transfert. |
| `serveur_dns_public` | ingress | 53 | tcp | voisins_site | clair | Repli TCP des notifications des primaires des locataires. | | `serveur_dns_public` | ingress | 53 | tcp | voisins_site | clair | Repli TCP des notifications des primaires des locataires. |
@ -86,11 +86,10 @@
| `serveur_ops` | ingress | 8090 | tcp | edge, admin | clair | Console d'exploitation servie par l'edge (TLS terminé à l'edge), et joignable depuis le plan d'administration là où il n'y a pas d'edge. Le GUI lui-même reste sur la boucle locale : c'est nginx qui authentifie devant. | | `serveur_ops` | ingress | 8090 | tcp | edge, admin | clair | Console d'exploitation servie par l'edge (TLS terminé à l'edge), et joignable depuis le plan d'administration là où il n'y a pas d'edge. Le GUI lui-même reste sur la boucle locale : c'est nginx qui authentifie devant. |
| `serveur_ops` | egress | 22 | tcp | flotte | ssh | Piloter la flotte — c'est la raison d'être du poste. | | `serveur_ops` | egress | 22 | tcp | flotte | ssh | Piloter la flotte — c'est la raison d'être du poste. |
| `serveur_ops` | egress | 443 | tcp | edge | tls-requis | Cloner et resynchroniser le génome depuis la forge de l'écosystème. | | `serveur_ops` | egress | 443 | tcp | edge | tls-requis | Cloner et resynchroniser le génome depuis la forge de l'écosystème. |
| `serveur_ops` | egress | 443 | tcp | voisins_site | tls-requis | Cloner le genome depuis la forge du site, quand cet ecosysteme n'heberge pas la sienne. |
| `serveur_ops` | egress | 8006 | tcp | externe | tls-requis | API de l'hyperviseur : créer et cloner les VM d'un écosystème descendant. |
| `serveur_ops_site` | egress | 22 | tcp | fabric | ssh | Shell des hyperviseurs : ce que l'API ne couvre pas — configuration reseau, ponts, deplacement de disques. Un pouvoir distinct de l'API, donc declare a part. | | `serveur_ops_site` | egress | 22 | tcp | fabric | ssh | Shell des hyperviseurs : ce que l'API ne couvre pas — configuration reseau, ponts, deplacement de disques. Un pouvoir distinct de l'API, donc declare a part. |
| `serveur_ops_site` | egress | 22 | tcp | serveur_ops_tenant | ssh | Insemination : amorcer le runner d'un tenant — socle, moteur, plan, plancher de resolution — pour qu'il prenne ensuite le relais sur ses propres machines. Ne transporte aucun secret : la voute est remise par un humain. | | `serveur_ops_site` | egress | 22 | tcp | serveur_ops_tenant | ssh | Insemination : amorcer le runner d'un tenant — socle, moteur, plan, plancher de resolution — pour qu'il prenne ensuite le relais sur ses propres machines. Ne transporte aucun secret : la voute est remise par un humain. |
| `serveur_ops_site` | egress | 443 | tcp | fabric | tls-requis | API de la frontiere OPNsense : poser les alias et les regles qui ouvrent les flux du tenant qu'on materialise. Preparer le terrain sans cela laisserait un terrain injoignable. | | `serveur_ops_site` | egress | 443 | tcp | fabric | tls-requis | API de la frontiere OPNsense : poser les alias et les regles qui ouvrent les flux du tenant qu'on materialise. Preparer le terrain sans cela laisserait un terrain injoignable. |
| `serveur_ops_site` | egress | 443 | tcp | externe | tls-requis | Repere du genome : `git ls-remote` anonyme sur eregion (forge.alliance-boreale.ca) pour dire si la forge du site a pris du retard. Lecture seule, sans identifiant. |
| `serveur_ops_site` | egress | 8006 | tcp | fabric | tls-requis | API de l'hyperviseur : créer, cloner et détruire les VM de la fabric. Le seul flux par lequel un écosystème peut en matérialiser un autre. | | `serveur_ops_site` | egress | 8006 | tcp | fabric | tls-requis | API de l'hyperviseur : créer, cloner et détruire les VM de la fabric. Le seul flux par lequel un écosystème peut en matérialiser un autre. |
| `serveur_ops_tenant` | ingress | 22 | tcp | runner_site | ssh | Insémination : le runner du SITE amorce ce runner-ci — socle, moteur, plan, plancher de résolution — jusqu'à ce qu'un humain lui remette sa voûte. Vers cette machine seule, jamais vers le reste de l'écosystème. | | `serveur_ops_tenant` | ingress | 22 | tcp | runner_site | ssh | Insémination : le runner du SITE amorce ce runner-ci — socle, moteur, plan, plancher de résolution — jusqu'à ce qu'un humain lui remette sa voûte. Vers cette machine seule, jamais vers le reste de l'écosystème. |
| `serveur_postfix` | ingress | 25 | tcp | externe, client_smtp | starttls | SMTP entrant : courrier externe (MX) et notifications internes (client_smtp). | | `serveur_postfix` | ingress | 25 | tcp | externe, client_smtp | starttls | SMTP entrant : courrier externe (MX) et notifications internes (client_smtp). |
@ -121,6 +120,8 @@
| `serveur_resolveur_site` | ingress | 53 | tcp | voisins_site | clair | Réponses longues et bascule TCP, obligatoires en DNS. | | `serveur_resolveur_site` | ingress | 53 | tcp | voisins_site | clair | Réponses longues et bascule TCP, obligatoires en DNS. |
| `serveur_rspamd` | ingress | 11332 | tcp | localhost | clair | Protocole milter consommé par Postfix co-localisé (analyse + signature DKIM). Local uniquement. | | `serveur_rspamd` | ingress | 11332 | tcp | localhost | clair | Protocole milter consommé par Postfix co-localisé (analyse + signature DKIM). Local uniquement. |
| `serveur_rspamd` | ingress | 11334 | tcp | localhost | clair | Interface de contrôle rspamd (statistiques, apprentissage) en local. | | `serveur_rspamd` | ingress | 11334 | tcp | localhost | clair | Interface de contrôle rspamd (statistiques, apprentissage) en local. |
| `serveur_rspamd` | egress | 80 | tcp | externe | clair | Carte de hachages SURBL (sa-update.surbl.org), publiee en clair par l'amont. Une carte alteree ne peut que mal classer un courriel ; elle n'execute rien. |
| `serveur_rspamd` | egress | 443 | tcp | externe | tls-requis | Cartes de rspamd (maps.rspamd.com) : messageries gratuites et jetables, redirecteurs, listes blanches DMARC/SPF/DKIM, hameconnage. Sans elles l'antispam garde les copies du paquet, qui vieillissent. |
| `serveur_step_ca` | ingress | 8443 | tcp | flotte | tls-requis | ACME + API step-ca : chaque nœud (client_pki) émet/renouvelle ses certificats et récupère la racine. | | `serveur_step_ca` | ingress | 8443 | tcp | flotte | tls-requis | ACME + API step-ca : chaque nœud (client_pki) émet/renouvelle ses certificats et récupère la racine. |
| `serveur_web_dorsal` | ingress | 80 | tcp | edge, serveur_web_frontal | clair | Front nginx local des webapps et des sites statiques, relaye par l'edge (noms internes) et par le web frontal (noms publics). | | `serveur_web_dorsal` | ingress | 80 | tcp | edge, serveur_web_frontal | clair | Front nginx local des webapps et des sites statiques, relaye par l'edge (noms internes) et par le web frontal (noms publics). |
| `serveur_web_dorsal` | egress | 443 | tcp | flotte | tls | git clone/pull du depot de chaque app (Forgejo souverain) au deploiement. | | `serveur_web_dorsal` | egress | 443 | tcp | flotte | tls | git clone/pull du depot de chaque app (Forgejo souverain) au deploiement. |
@ -130,7 +131,7 @@
## Synthèse chiffrement ## Synthèse chiffrement
- **clair** : 51 flux - **clair** : 52 flux
- **n-a** : 8 flux - **n-a** : 8 flux
- **ssh** : 8 flux - **ssh** : 8 flux
- **starttls** : 6 flux - **starttls** : 6 flux

View file

@ -99,7 +99,7 @@ Nommer un point ouvert vaut mieux qu'une ligne rassurante sans mécanisme derri
Aucune de ces responsabilités n'est laissée à la parole : Aucune de ces responsabilités n'est laissée à la parole :
``` ```
make prouver le dépôt est-il cohérent avec lui-même (83 preuves, zéro réseau) make prouver le dépôt est-il cohérent avec lui-même (94 preuves, zéro réseau)
make remise-verifier le second temps de la remise est-il fait, ou en retard ? (P80) make remise-verifier le second temps de la remise est-il fait, ou en retard ? (P80)
make certificats-plan ce que le disque porte contre ce que la mémoire sert make certificats-plan ce que le disque porte contre ce que la mémoire sert
make expositions-plan chaque service publié répond-il, et depuis où make expositions-plan chaque service publié répond-il, et depuis où

View file

@ -94,6 +94,10 @@ variables:
invite: "Armer sans marquer la pause" invite: "Armer sans marquer la pause"
valeurs: [oui] valeurs: [oui]
facultatif: true facultatif: true
SOURCE:
invite: "Comparer a quel instantane (vide = celui d'avant rasage)"
valeurs: [candidat]
facultatif: true
VMID: VMID:
invite: "Identifiant Proxmox de la VM" invite: "Identifiant Proxmox de la VM"
DIALECTE: DIALECTE:
@ -209,6 +213,12 @@ runbooks:
- cible: site-intrants - cible: site-intrants
nature: mesure nature: mesure
pourquoi: "Ce que ce site expose a ses locataires, derive et non declare deux fois." pourquoi: "Ce que ce site expose a ses locataires, derive et non declare deux fois."
- cible: fiches-site-deposer
nature: ecriture
pourquoi: >-
Deposer chez chaque locataire la fiche que le site lui destine. Son runner n'a pas
le depot du site : c'est par elle que son inventaire se genere sans lui. A commiter
dans le depot de chaque locataire.
- cible: site-verifier - cible: site-verifier
nature: mesure nature: mesure
pourquoi: "Le playbook du site correspond-il encore aux couches declarees ?" pourquoi: "Le playbook du site correspond-il encore aux couches declarees ?"
@ -218,6 +228,21 @@ runbooks:
- cible: routes-fabric-etat - cible: routes-fabric-etat
nature: mesure nature: mesure
pourquoi: "Une route de zone manquante est invisible jusqu'au premier invite qui la traverse." pourquoi: "Une route de zone manquante est invisible jusqu'au premier invite qui la traverse."
- cible: locataire-creer
nature: ecriture
variables: [TENANT, PARALLELE]
fixes: {CONFIRMER: "true"}
pourquoi: >-
Cloner les VM d'un locataire NOMME, d'apres la face reseau qu'il publie : le
runner du site materialise sans monter son depot. Memes machines, memes
parametres que `flotte-creer` sur l'instance montee (P93, P94).
- cible: locataire-raser
nature: destructif
variables: [TENANT, INSTANCE]
fixes: {CONFIRMER: "true"}
pourquoi: >-
Detruire les VM d'un locataire NOMME, d'apres sa face : les memes VMID que le
plan derive (P94). Son nom court s'ecrit en toutes lettres, comme pour `raser`.
- cible: site-raser - cible: site-raser
nature: destructif nature: destructif
variables: [SITE] variables: [SITE]
@ -320,6 +345,12 @@ runbooks:
pourquoi: >- pourquoi: >-
Regenerer le registre des flux et les regles nftables depuis les `meta/flux.yml` Regenerer le registre des flux et les regles nftables depuis les `meta/flux.yml`
des roles. Tout ce qui suit en descend. des roles. Tout ce qui suit en descend.
- cible: face-reseau-publier
nature: ecriture
pourquoi: >-
Publier ce que ce locataire demande a son site (`face-reseau.yml`) : ses machines,
ses zones, ses flux deja resolus. Le site ne lit plus que ce fichier. Apres `flux`,
et a commiter dans le depot du locataire.
- cible: devis-proxmox-fw - cible: devis-proxmox-fw
nature: mesure nature: mesure
pourquoi: "Le pare-feu est-ouest intra-locataire, derive du registre des flux." pourquoi: "Le pare-feu est-ouest intra-locataire, derive du registre des flux."
@ -492,7 +523,8 @@ runbooks:
pourquoi: >- pourquoi: >-
Deposer l'etat de chaque noeud JUSTE AVANT de raser. La reconstruction remet le 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 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 - cible: raser
nature: destructif nature: destructif
portee: poste portee: poste
@ -515,6 +547,14 @@ runbooks:
pourquoi: >- pourquoi: >-
Ce que chaque jeu est devenu : restaure (et depuis quel instantane), neuf, en 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. 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 - cible: restauration-renoncer
nature: destructif nature: destructif
variables: [HOTE, JEU] variables: [HOTE, JEU]

View file

@ -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 écarté : sinon `restic forget --keep-daily` chasserait l'instantané d'avant au profit de
l'état neuf du même jour. 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 : Les gestes :
``` ```
make sauvegarder-maintenant # JUSTE AVANT de raser : sinon on perd la journée make sauvegarder-maintenant # JUSTE AVANT de raser : étiqueté « avant-raser »
make restauration-etat [HOTE=...] # ce que chaque jeu est devenu 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 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 -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`, rien — `setops-restaurer annuaire --essai <base_dn>`, `base <nom> --vers epreuve`,
`fichiers --vers /var/tmp/epreuve <chemin>`. `fichiers --vers /var/tmp/epreuve <chemin>`.

View file

@ -6,6 +6,10 @@
# make sauvegarder-maintenant [HOTE=...] # make sauvegarder-maintenant [HOTE=...]
# deposer TOUT DE SUITE, et attendre que ce soit fait. A faire juste avant `raser` : # 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 ; # 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 # make restauration-renoncer HOTE=<hote> JEU=<jeu> CONFIRMER=true
# ECARTER un etat anterieur sans le remettre. La sauvegarde du noeud, qui refusait # 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 # de deposer pour ne pas le chasser de la retention, reprend — et l'etat d'avant
@ -20,6 +24,7 @@
vars: vars:
restauration_action: etat restauration_action: etat
restauration_jeu: "" restauration_jeu: ""
restauration_source: avant-raser
tasks: tasks:
- name: Refuser un renoncement sans jeu nomme - name: Refuser un renoncement sans jeu nomme
ansible.builtin.assert: ansible.builtin.assert:
@ -49,14 +54,66 @@
register: restauration_unite register: restauration_unite
when: restauration_action == 'sauvegarder' 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: ansible.builtin.systemd:
name: setops-sauvegarde.service name: setops-sauvegarde-avant-raser.service
state: started state: started
when: when:
- restauration_action == 'sauvegarder' - restauration_action == 'sauvegarder'
- restauration_unite.stat.exists - 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 - name: Ecarter l'etat anterieur de ce jeu
ansible.builtin.command: ansible.builtin.command:
argv: [/usr/local/sbin/setops-restaurer, acter, "{{ restauration_jeu }}", abandonne] argv: [/usr/local/sbin/setops-restaurer, acter, "{{ restauration_jeu }}", abandonne]

View file

@ -160,6 +160,7 @@
loop: "{{ genome_a_pousser }}" loop: "{{ genome_a_pousser }}"
loop_control: loop_control:
label: "{{ item.dest }}" label: "{{ item.dest }}"
register: genome_emballe
changed_when: true changed_when: true
- name: Porter les colis jusqu'au runner - name: Porter les colis jusqu'au runner
@ -255,6 +256,45 @@
label: "{{ item.depot }}" label: "{{ item.depot }}"
index_var: idx index_var: idx
# LES RELEASES AUSSI (2026-10-07). Le colis ne portait que la branche : `v2026.08.21` et
# `v2026.10.07` manquaient a la forge du site. Il porte desormais les etiquettes ; on
# demande a la forge si elle a TOUTES celles du poste. 404 = aucune etiquette sur ce
# depot, ce qui est juste quand le poste n'en a pas non plus.
- name: Demander à la forge les étiquettes qu'elle porte
ansible.builtin.uri:
url: >-
https://forge.{{ domaine_interne }}/api/v1/repos/{{ serveur_ops_forge_organisation
| default('genome') }}/{{ item.depot }}/git/refs/tags
validate_certs: false
status_code: [200, 404]
loop: "{{ genome_a_pousser }}"
loop_control:
label: "{{ item.depot }}"
register: genome_forge_etiquettes
retries: 4
delay: 3
until: genome_forge_etiquettes is succeeded
changed_when: false
- name: La forge porte-t-elle toutes les releases du poste ?
vars:
genome_etiquettes_poste: "{{ (genome_emballe.results[idx].stdout | from_json).etiquettes | default([]) }}"
genome_etiquettes_forge: >-
{{ (genome_forge_etiquettes.results[idx].json | default([]) if genome_forge_etiquettes.results[idx].status == 200 else [])
| map(attribute='ref') | map('regex_replace', '^refs/tags/', '') | list }}
ansible.builtin.assert:
that:
- genome_etiquettes_poste | difference(genome_etiquettes_forge) | length == 0
fail_msg: >-
{{ item.depot }} : la forge n'a pas
{{ genome_etiquettes_poste | difference(genome_etiquettes_forge) | join(', ') }}.
success_msg: >-
{{ item.depot }} : {{ genome_etiquettes_poste | length }} etiquette(s) du poste, toutes sur la forge.
loop: "{{ genome_a_pousser }}"
loop_control:
label: "{{ item.depot }}"
index_var: idx
# LE RUNNER DU SITE AVANCE SON PROPRE CLONE, ET SA CONSOLE NE LE SAIT PAS (2026-09-20). # LE RUNNER DU SITE AVANCE SON PROPRE CLONE, ET SA CONSOLE NE LE SAIT PAS (2026-09-20).
# #
# C'est LUI qui pousse, donc il doit d'abord recevoir : ce playbook fait avancer le # C'est LUI qui pousse, donc il doit d'abord recevoir : ce playbook fait avancer le

View file

@ -11,11 +11,23 @@
gather_facts: false gather_facts: false
become: false become: false
tasks: tasks:
- name: Interroger l'API des cibles actives # UN PROMETHEUS QUI VIENT DE REDEMARRER N'A PAS ENCORE RELEVE SES CIBLES (2026-10-08).
# Depuis qu'obs-01 est sauvegarde, son premier depot (copie a froid, en fin de
# deploiement) relance Prometheus : a la reconstruction de Technolibre, la recette l'a
# interroge 18 s apres, et deux cibles n'avaient pas encore de releve reussi — saines
# quelques minutes plus tard. On redemande donc, une minute au plus, avant de juger :
# une vraie panne echoue toujours, une minute plus tard.
- name: Interroger l'API des cibles actives (jusqu'a ce que toutes soient relevees, une minute au plus)
ansible.builtin.uri: ansible.builtin.uri:
url: "http://localhost:9090/api/v1/targets?state=active" url: "http://localhost:9090/api/v1/targets?state=active"
return_content: true return_content: true
register: prom_targets register: prom_targets
until: >-
((prom_targets.content | default('{}') | from_json).get('data', {}).get('activeTargets', [])
| rejectattr('health', 'equalto', 'up') | list | length == 0)
or (prom_targets.attempts | default(0) | int >= 6)
retries: 6
delay: 10
- name: Analyser up / down - name: Analyser up / down
vars: vars:

View file

@ -45,6 +45,7 @@ Sans eux, le rôle refuse de s'exécuter (assertion).
| `client_backup_repo` | `sftp:restic@<cible>:<hôte>` | Un sous-dossier par nœud | | `client_backup_repo` | `sftp:restic@<cible>:<hôte>` | Un sous-dossier par nœud |
| `client_backup_staging` | `/var/backups/setops` | Préparation des dumps | | `client_backup_staging` | `/var/backups/setops` | Préparation des dumps |
| `client_backup_retention` | `--keep-daily 7 --keep-weekly 4 --keep-monthly 6` | `restic forget` | | `client_backup_retention` | `--keep-daily 7 --keep-weekly 4 --keep-monthly 6` | `restic forget` |
| `client_backup_etiquette_avant_raser` | `avant-raser` | étiquette du dépôt d'avant rasage, gardée par la rétention jusqu'à la reconstruction suivante |
| `client_backup_horaire` | `*-*-* 02:30:00` | Timer systemd | | `client_backup_horaire` | `*-*-* 02:30:00` | Timer systemd |
| `client_backup_jobs` | `[]` | **À déclarer par nœud** | | `client_backup_jobs` | `[]` | **À déclarer par nœud** |
@ -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 - 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 ; 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). l'outil de nœud est `setops-restaurer` (cf. `docs/runbooks-exploitation.md` §5.0).
- Témoins : `setops-restaurer temoins` (comparateur `setops-temoins`) confronte l'état vivant
à l'instantané d'avant rasage ; `make temoins-etat`, et l'étape `bilan` de la reconstruction.
## Prérequis ## Prérequis
- `serveur_backup` déployé, et sa `serveur_backup_pubkey` correspondant à la clé privée de - `serveur_backup` déployé, et sa `serveur_backup_pubkey` correspondant à la clé privée de

View file

@ -20,6 +20,10 @@ client_backup_staging: "/var/backups/setops"
# Rétention (restic forget). # Rétention (restic forget).
client_backup_retention: "--keep-daily 7 --keep-weekly 4 --keep-monthly 6" client_backup_retention: "--keep-daily 7 --keep-weekly 4 --keep-monthly 6"
# L'instantane d'avant rasage (`make sauvegarder-maintenant`) porte cette etiquette, et la
# retention la garde EN PLUS de la regle ci-dessus : jusqu'a la reconstruction suivante
# (decision de l'exploitant, 2026-10-07). Voir `templates/sauvegarder.sh.j2`.
client_backup_etiquette_avant_raser: "avant-raser"
# Planification (timer systemd). # Planification (timer systemd).
client_backup_horaire: "*-*-* 02:30:00" client_backup_horaire: "*-*-* 02:30:00"
@ -79,6 +83,39 @@ client_backup_catalogue:
# PAS DE `serveur_web_frontal` (retire le 2026-09-30) : le frontal RELAIE, il ne sert # 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. # 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. # `/srv/web` venait de l'epoque ou il servait du statique ; ses instantanes etaient vides.
# LES METRIQUES ET LES JOURNAUX (2026-10-07). Ils repartaient de zero a chaque
# reconstruction. Ils s'ecrivent en continu : une copie A FROID (service arrete le temps
# de copier, `setops-copie-a-froid`) les pose dans le repertoire de preparation, et c'est
# cette copie que restic emporte. `vivant` dit ou vit la base, pour la restauration et les
# temoins, qui la comparent a l'instantane.
serveur_prometheus:
nom: prometheus
vivant: "{{ serveur_prometheus_donnees | default('/var/lib/prometheus/metrics2') }}"
commande: >-
/usr/local/sbin/setops-copie-a-froid
{{ serveur_prometheus_service | default('prometheus') }}
{{ serveur_prometheus_donnees | default('/var/lib/prometheus/metrics2') }}
{{ client_backup_staging }}/prometheus/metrics2 --blocs-immuables
chemins: ["{{ client_backup_staging }}/prometheus"]
serveur_loki:
nom: loki
vivant: "{{ serveur_loki_chemin | default('/var/lib/loki') }}"
commande: >-
/usr/local/sbin/setops-copie-a-froid
{{ serveur_loki_service | default('loki') }}
{{ serveur_loki_chemin | default('/var/lib/loki') }}
{{ client_backup_staging }}/loki/donnees --lier chunks --copier chunks/index
chemins: ["{{ client_backup_staging }}/loki"]
serveur_icinga:
# L'AC D'ICINGA FAIT L'IDENTITE DE SA BASE (2026-10-07). Icinga 2 derive l'environnement
# d'Icinga DB de la cle publique de son AC, et chaque identifiant d'hote, de service et
# d'historique de cet environnement. Recreee a chaque reconstruction, elle rendait
# l'historique restaure orphelin : on l'avait donc jete (decision du 2026-09-30, revue
# le 2026-10-07 — l'historique, c'est la disponibilite qu'on doit pouvoir prouver).
nom: icinga
chemins:
- "{{ (serveur_icinga_ca | default('/var/lib/icinga2/ca/ca.crt')) | dirname }}"
- /var/lib/icinga2/icingadb.env
serveur_web_dorsal: serveur_web_dorsal:
nom: web_dorsal nom: web_dorsal
chemins: ["{{ serveur_web_dorsal_racine | default('/srv/webapp') }}"] chemins: ["{{ serveur_web_dorsal_racine | default('/srv/webapp') }}"]

View 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")"

View file

@ -0,0 +1,432 @@
#!/usr/bin/env python3
# Géré par Set-OPS (rôle client_backup). Ne pas éditer à la main.
"""Témoins d'état : l'état VIVANT de ce noeud est-il celui qu'il avait avant le rasage ?
POURQUOI (2026-10-07). Le bilan d'une reconstruction dit ce que chaque rôle a FAIT
(« restauré depuis tel instantané ») : c'est le système qui juge son propre travail. Le
2026-09-30, les empreintes avaient été relevées à la main, avant et après ; rien ne le
gardait dans le dépôt. Le 2026-10-07 (M4), l'instantané d'avant le rasage a même disparu
du dépôt dans l'heure, chassé par la rétention : il ne restait rien à quoi comparer.
Ce script compare l'instantané extrait par `setops-restaurer temoins` (sous `--avant`) à ce
qui vit sur le noeud. Il ne lit que ; il n'écrit rien.
VERDICTS, PAR JEU :
IDENTIQUE rien ne diffère ;
CONFORME rien de perdu, l'identité est intacte ; des changements ordinaires, listés
(un courriel reçu depuis, une table de sessions, le bayes de rspamd) ;
ECART une PERTE (fichier, entrée, base, table), ou un objet d'IDENTITÉ changé :
clés et certificats de l'AC, AC d'Icinga et son environnement, clé DKIM,
`instanceid` de Nextcloud, mot de passe d'une entrée de l'annuaire ; une
base dont plus aucune ligne d'avant ne subsiste ; une ligne d'historique
perdue ; des métriques qui ne couvrent plus l'avant-rasage ; des journaux
dont aucun morceau d'avant ne subsiste. Exactement ce que la reconstruction du
2026-09-30 avait perdu sans que rien ne le dise.
Code de sortie : 0 sans écart, 1 avec au moins un écart, 2 erreur d'usage.
"""
from __future__ import annotations
import argparse
import base64
import collections
import hashlib
import json
import os
import re
import subprocess
import sys
from pathlib import Path
LISTE_MAX = 8 # détails affichés par catégorie ; le compte, lui, est toujours entier
# Attributs que l'annuaire tient lui-même : ils changent à chaque écriture, ou à chaque
# rejeu (`slapadd` les repose), sans que la donnée ait bougé.
OPERATIONNELS = {"entrycsn", "modifytimestamp", "modifiersname", "createtimestamp",
"creatorsname", "contextcsn"}
class Rapport:
def __init__(self, jeu: str) -> None:
self.jeu = jeu
self.ecarts: list[str] = [] # pertes et identités changées
self.changes: list[str] = [] # changements ordinaires
self.retires: list[str] = [] # disparitions ordinaires (un cache, une base compactee)
self.nouveaux: list[str] = []
self.compte = 0 # objets comparés
def verdict(self) -> str:
if self.ecarts:
return "ECART"
return "CONFORME" if (self.changes or self.retires or self.nouveaux) else "IDENTIQUE"
def lignes(self, unite: str) -> list[str]:
out = [f" {self.jeu:<12} {self.verdict():<10} {self.compte} {unite}"]
for titre, liste in (("ECART", self.ecarts), ("modifie", self.changes),
("retire", self.retires), ("nouveau", self.nouveaux)):
for x in liste[:LISTE_MAX]:
out.append(f" {titre:<8} {x}")
if len(liste) > LISTE_MAX:
out.append(f" {titre:<8} ... et {len(liste) - LISTE_MAX} autre(s)")
return out
def empreinte(p: Path) -> str:
h = hashlib.sha256()
with p.open("rb") as f:
for bloc in iter(lambda: f.read(1 << 20), b""):
h.update(bloc)
return h.hexdigest()
def arbre(racine: Path, normaliser=None) -> dict[str, str]:
"""{chemin relatif (normalisé) : sha256} des fichiers réguliers sous `racine`."""
if racine.is_file():
return {racine.name: empreinte(racine)}
out: dict[str, str] = {}
if not racine.is_dir():
return out
for p in sorted(racine.rglob("*")):
if p.is_file() and not p.is_symlink():
rel = p.relative_to(racine).as_posix()
out[normaliser(rel) if normaliser else rel] = empreinte(p)
return out
def comparer_arbres(r: Rapport, avant: dict[str, str], vivant: dict[str, str], *,
strict, perte_stricte) -> None:
"""`strict(rel)` : un changement est un écart ; `perte_stricte(rel)` : une perte aussi."""
r.compte += len(avant)
for rel, h in sorted(avant.items()):
if rel not in vivant:
if perte_stricte(rel):
r.ecarts.append(f"perdu : {rel}")
else:
r.retires.append(rel)
elif vivant[rel] != h:
(r.ecarts if strict(rel) else r.changes).append(f"{'IDENTITE ' if strict(rel) else ''}{rel}")
r.nouveaux.extend(sorted(set(vivant) - set(avant)))
def jamais(_rel: str) -> bool:
return False
def toujours(_rel: str) -> bool:
return True
# --- Les jeux de fichiers ------------------------------------------------------------
def maildir(rel: str) -> str:
"""Un message garde son nom de base : `new/` -> `cur/` à la lecture, `:2,S` aux drapeaux."""
rel = re.sub(r"(^|/)(new|cur)/", r"\1msg/", rel)
return re.sub(r":2,[A-Za-z]*$", "", rel)
def est_message(rel: str) -> bool:
return "/msg/" in f"/{rel}"
def jeu_fichiers(jeu: str, chemins: list[str], avant: Path) -> Rapport:
r = Rapport(jeu)
for c in chemins:
a_avant, a_vivant = avant / c.lstrip("/"), Path(c)
if jeu == "step_ca":
# Les clés et les certificats SONT l'AC : un octet de différence et plus rien
# de ce qu'elle a signé ne se vérifie. Sa base (badger) se compacte d'elle-même.
identite = lambda rel: rel.startswith(("certs/", "secrets/")) # noqa: E731
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=identite,
perte_stricte=identite)
elif jeu == "icinga":
# L'AC d'Icinga et l'environnement qu'elle derive : changes, chaque identifiant
# d'Icinga DB change, et tout l'historique restaure devient orphelin.
identite = lambda rel: rel in ("ca.crt", "ca.key", "icingadb.env") # noqa: E731
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=identite,
perte_stricte=identite)
elif jeu == "courriel":
comparer_arbres(r, arbre(a_avant, maildir), arbre(a_vivant, maildir),
strict=jamais, perte_stricte=est_message)
elif jeu == "rspamd":
# La clé DKIM est l'identité du domaine ; le bayes, lui, apprend tous les jours.
dkim = lambda rel: rel.startswith("dkim/") # noqa: E731
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=dkim, perte_stricte=dkim)
elif jeu == "nextcloud":
if c.rstrip("/").endswith("/config"):
instance_id(r, a_avant / "config.php", a_vivant / "config.php")
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=jamais,
perte_stricte=jamais)
else:
# Les fichiers des PERSONNES ; `appdata_*` est un cache que Nextcloud refait.
fichier = lambda rel: re.match(r"^(?!appdata_)[^/]+/files/", rel) is not None # noqa: E731
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=jamais,
perte_stricte=fichier)
else:
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=jamais,
perte_stricte=toujours)
return r
def instance_id(r: Rapport, avant: Path, vivant: Path) -> None:
def lire(p: Path) -> str:
m = re.search(r"'instanceid'\s*=>\s*'([^']+)'", p.read_text(errors="replace")) if p.is_file() else None
return m.group(1) if m else ""
a, v = lire(avant), lire(vivant)
if a and a != v:
r.ecarts.append(f"IDENTITE instanceid : {a} -> {v or '(absent)'}")
# --- Les bases copiees a froid : Prometheus et Loki (2026-10-07) ----------------------
#
# Leurs FICHIERS ne se comparent pas : Prometheus fusionne ses blocs, Loki compacte son
# index, et la retention (15 j, 31 j) en retire chaque jour. On compare ce qui compte.
def blocs_prometheus(racine: Path) -> list[tuple[int, int]]:
out = []
for meta in racine.glob("01*/meta.json") if racine.is_dir() else []:
try:
m = json.loads(meta.read_text())
out.append((int(m["minTime"]), int(m["maxTime"])))
except (ValueError, KeyError):
continue
return sorted(out)
def segments_wal(racine: Path) -> list[str]:
return sorted(p.name for p in (racine / "wal").glob("[0-9]*")) if (racine / "wal").is_dir() else []
def jeu_prometheus(avant: Path, vivant: Path) -> Rapport:
"""Les métriques vivantes couvrent-elles encore l'avant-rasage ?
Le repère : le DÉBUT du bloc le plus récent de l'instantané — quelques heures avant le
rasage. Un bloc vivant doit couvrir cet instant. La rétention ne l'a pas retiré (il a
moins de 15 jours) ; une base qui n'a pas été restaurée ne peut pas le couvrir : elle
est née après la reconstruction. Une base de moins de deux heures n'a pas encore de
bloc : son WAL fait alors le repère, et doit se retrouver dans la base vivante, ou y
avoir été fermé en bloc.
"""
r = Rapport("prometheus")
a, v = blocs_prometheus(avant), blocs_prometheus(vivant)
r.compte = len(a)
if a:
t = max(debut for debut, _ in a)
if not any(debut <= t < fin for debut, fin in v):
r.ecarts.append(f"aucun bloc vivant ne couvre {t} (debut du dernier bloc d'avant) : "
f"metriques d'avant non restaurees")
if v and min(d for d, _ in v) > min(d for d, _ in a):
r.changes.append(f"plus ancienne donnee : {min(d for d, _ in a)} -> {min(d for d, _ in v)} (retention)")
else:
wal = segments_wal(avant)
if wal and not v and wal[0] not in segments_wal(vivant):
r.ecarts.append(f"le WAL d'avant ({wal[0]}...) n'est pas dans la base vivante, et elle n'a "
f"aucun bloc : metriques d'avant non restaurees")
if len(v) != len(a):
r.changes.append(f"{len(a)} bloc(s) avant, {len(v)} maintenant (fusions, retention, nouveaux)")
return r
def jeu_loki(avant: Path, vivant: Path) -> Rapport:
"""Des morceaux de journal d'avant subsistent-ils ? Aucun : non restaurés.
La rétention (31 j) en retire chaque jour : une perte PARTIELLE est listée. Une perte
TOTALE, elle, ne vient que d'une base qui n'a pas été remise.
"""
r = Rapport("loki")
# LES MORCEAUX, PAS L'INDEX (2026-10-08). `chunks/index/` loge l'index TSDB et la base du
# compacteur : il les fusionne et les remplace. A la reconstruction de Technolibre, trois
# de ces fichiers « d'avant » manquaient, et le temoin les disait retires « (retention ?) »
# — aucun journal ne l'etait (Loki rendait ceux d'avant le rasage, minute par minute).
morceaux = lambda racine: {rel for rel in arbre(racine / "chunks") if not rel.startswith("index/")} # noqa: E731
a = morceaux(avant)
v = morceaux(vivant)
r.compte = len(a)
if a and not (a & v):
r.ecarts.append(f"aucun des {len(a)} morceau(x) de journal d'avant ne subsiste : journaux non restaures")
elif a - v:
r.retires.append(f"{len(a - v)} morceau(x) d'avant retire(s) (retention ?)")
if v - a:
r.nouveaux.append(f"{len(v - a)} morceau(x) nouveau(x)")
return r
# --- L'annuaire ----------------------------------------------------------------------
def entrees_ldif(texte: str) -> dict[str, dict[str, list[str]]]:
"""{dn : {attribut : [valeurs triées]}}, attributs opérationnels exclus."""
deplie = re.sub(r"\n ", "", texte.replace("\r\n", "\n"))
out: dict[str, dict[str, list[str]]] = {}
for bloc in deplie.split("\n\n"):
attrs: dict[str, list[str]] = {}
dn = ""
for ligne in bloc.splitlines():
if not ligne or ligne.startswith("#") or ":" not in ligne:
continue
nom, _, val = ligne.partition(":")
if val.startswith(":"):
val = base64.b64decode(val[1:].strip()).decode("utf-8", "replace")
else:
val = val.strip()
if nom.lower() == "dn":
dn = val.lower()
elif nom.lower() not in OPERATIONNELS:
attrs.setdefault(nom.lower(), []).append(val)
if dn:
out[dn] = {k: sorted(v) for k, v in attrs.items()}
return out
def jeu_annuaire(avant_ldif: Path, vivant_ldif: Path) -> Rapport:
r = Rapport("openldap")
if not avant_ldif.is_file():
r.ecarts.append(f"l'instantane n'emporte pas d'annuaire ({avant_ldif.name})")
return r
a = entrees_ldif(avant_ldif.read_text(errors="replace"))
v = entrees_ldif(vivant_ldif.read_text(errors="replace")) if vivant_ldif.is_file() else {}
r.compte = len(a)
for dn, attrs in sorted(a.items()):
if dn not in v:
r.ecarts.append(f"perdue : {dn}")
continue
if attrs == v[dn]:
continue
# Le mot de passe d'une entrée, c'est ce que la reconstruction du 2026-09-30 avait
# remis à la valeur d'amorçage sans que rien ne le dise.
if attrs.get("userpassword") != v[dn].get("userpassword"):
r.ecarts.append(f"IDENTITE userPassword : {dn}")
diff = sorted(k for k in set(attrs) | set(v[dn])
if k != "userpassword" and attrs.get(k) != v[dn].get(k))
if diff:
r.changes.append(f"{dn} ({', '.join(diff)})")
r.nouveaux.extend(sorted(set(v) - set(a)))
return r
# --- PostgreSQL ----------------------------------------------------------------------
def lire_dump(texte: str) -> tuple[set[str], set[str], dict[tuple[str, str, str], list[str]]]:
"""(bases, rôles, {(base, table, 1re colonne) : [valeurs]}) d'après un `pg_dumpall`."""
bases = set(re.findall(r"^CREATE DATABASE (\S+) ", texte, re.M))
roles = set(re.findall(r"^CREATE ROLE (\S+);", texte, re.M))
cles: dict[tuple[str, str, str], list[str]] = {}
base, table = "", None
for ligne in texte.splitlines():
if table is not None:
if ligne == "\\.":
table = None
else:
cles[table].append(ligne.split("\t", 1)[0])
elif ligne.startswith("\\connect "):
base = ligne.split()[-1]
else:
m = re.match(r"^COPY (\S+) \(([^,)]+)[,)].*FROM stdin;$", ligne)
if m:
table = (base, m.group(1), m.group(2).strip())
cles[table] = []
return bases, roles, cles
def psql(base: str, sql: str) -> list[str] | None:
r = subprocess.run(["runuser", "-u", "postgres", "--", "psql", "-tAX", "-d", base, "-c", sql],
capture_output=True, text=True, env=dict(os.environ, PAGER="cat"))
return r.stdout.splitlines() if r.returncode == 0 else None
def jeu_postgresql(dump: Path, interroger=psql) -> Rapport:
"""Par CLÉ, pas par nombre de lignes (2026-10-07).
Compter ne voyait rien : l'historique d'Icinga DB, entièrement remplacé (412 lignes
d'avant, toutes perdues ; 380 neuves), passait pour « 412 -> 380 ». On compare donc la
première colonne de chaque table, lue au même format que le dump (`COPY ... TO STDOUT`).
Ce qui est un ÉCART, d'après la mesure sur Chezlepro (avant-raser contre vivant) :
- une base dont AUCUNE ligne d'avant ne subsiste, toutes tables confondues : elle n'a
pas été restaurée (c'était icingadb, ses 30 tables renouvelées) ;
- une ligne perdue dans une table d'HISTORIQUE (`*history*`) : par nature, on y
ajoute, on n'y retire pas.
Le reste est listé : la découverte de grappe de Keycloak (`jgroups_ping`), ses sessions,
une configuration de composant régénérée renouvellent leurs clés dans la vie ordinaire.
"""
r = Rapport("postgresql")
if not dump.is_file():
r.ecarts.append(f"l'instantane n'emporte pas de dump ({dump.name})")
return r
bases, roles, cles = lire_dump(dump.read_text(errors="replace"))
vivantes = set(interroger("postgres", "select datname from pg_database") or [])
roles_vivants = set(interroger("postgres", "select rolname from pg_roles") or [])
for b in sorted(bases - vivantes):
r.ecarts.append(f"base perdue : {b}")
for x in sorted(roles - roles_vivants):
r.ecarts.append(f"role perdu : {x}")
par_base: dict[str, list[int]] = collections.defaultdict(lambda: [0, 0])
for (b, t, col), avant in sorted(cles.items()):
if b not in vivantes or not avant:
continue
r.compte += 1
vivant = interroger(b, f"COPY (select {col} from {t}) TO STDOUT")
if vivant is None:
r.ecarts.append(f"table perdue : {b}.{t}")
continue
a, v = set(avant), set(vivant)
communes = len(a & v)
par_base[b][0] += len(a)
par_base[b][1] += communes
perdues, nouvelles = len(a) - communes, len(v - a)
if perdues and "history" in t:
r.ecarts.append(f"historique perdu : {b}.{t} : {perdues} ligne(s) d'avant sur {len(a)}")
elif perdues or nouvelles:
r.changes.append(f"{b}.{t} : {perdues} retiree(s), {nouvelles} nouvelle(s) (avant {len(a)})")
for b, (n, communes) in sorted(par_base.items()):
if n and not communes:
r.ecarts.insert(0, f"base {b} : aucune des {n} ligne(s) d'avant ne subsiste — non restauree")
# `template0` n'est jamais dans un `pg_dumpall` : il n'est pas « nouveau », il est hors jeu.
r.nouveaux.extend(f"base {b}" for b in sorted(vivantes - bases - {"postgres", "template0"}))
return r
# --- Programme -----------------------------------------------------------------------
def main(argv: list[str] | None = None, interroger=psql) -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--avant", required=True, type=Path, help="racine où l'instantané est extrait")
ap.add_argument("--instantane", required=True)
ap.add_argument("--heure", default="")
ap.add_argument("--hote", default="")
ap.add_argument("--jeu", action="append", default=[], help="NOM=CHEMIN[:CHEMIN...]")
ap.add_argument("--vivant", action="append", default=[],
help="NOM=CHEMIN : ou vit la base d'un jeu copie a froid")
ap.add_argument("--ldif", default="", help="chemin du LDIF dans l'instantané")
ap.add_argument("--ldif-vivant", type=Path, default=None)
ap.add_argument("--dump", default="", help="chemin du pg_dumpall dans l'instantané")
a = ap.parse_args(argv)
print(f"TEMOINS de {a.hote} : instantane {a.instantane} ({a.heure}) contre l'etat vivant")
if not a.jeu:
print(" SANS OBJET : ce noeud ne detient aucun etat.")
return 0
ecart = False
vivants = dict(v.partition("=")[::2] for v in a.vivant)
for spec in a.jeu:
nom, _, chemins = spec.partition("=")
# La copie a froid est le SEUL sous-dossier de la preparation du jeu.
copie = lambda: next((d for d in sorted((a.avant / chemins.split(":")[0].lstrip("/")).glob("*")) # noqa: E731
if d.is_dir()), a.avant / "absent")
if nom == "prometheus" and nom in vivants:
r, unite = jeu_prometheus(copie(), Path(vivants[nom])), "bloc(s)"
elif nom == "loki" and nom in vivants:
r, unite = jeu_loki(copie(), Path(vivants[nom])), "morceau(x)"
elif nom == "openldap":
r, unite = jeu_annuaire(a.avant / a.ldif.lstrip("/"), a.ldif_vivant or Path("/nonexistent")), "entree(s)"
elif nom == "postgresql":
r, unite = jeu_postgresql(a.avant / a.dump.lstrip("/"), interroger), "table(s)"
else:
r, unite = jeu_fichiers(nom, [c for c in chemins.split(":") if c], a.avant), "fichier(s)"
print("\n".join(r.lignes(unite)))
ecart = ecart or bool(r.ecarts)
print("ECART : une perte, ou une identite qui a change." if ecart
else "Aucun ecart : rien de perdu, l'identite est intacte.")
return 1 if ecart else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -66,3 +66,13 @@
owner: root owner: root
group: root group: root
mode: "0700" 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"

View file

@ -56,6 +56,17 @@
group: root group: root
mode: "0700" mode: "0700"
# LA COPIE A FROID (2026-10-07) : les jeux qui s'ecrivent en continu (Prometheus, Loki)
# arretent leur service le temps de copier. L'outil est deploye avec le script qui l'appelle.
- name: Déployer l'outil de copie à froid
when: client_backup_jobs | length > 0
ansible.builtin.copy:
src: setops-copie-a-froid.sh
dest: /usr/local/sbin/setops-copie-a-froid
owner: root
group: root
mode: "0700"
- name: Déployer l'unité et le timer systemd - name: Déployer l'unité et le timer systemd
when: client_backup_jobs | length > 0 when: client_backup_jobs | length > 0
ansible.builtin.template: ansible.builtin.template:
@ -67,6 +78,7 @@
loop: loop:
- { s: setops-sauvegarde.service.j2, d: setops-sauvegarde.service } - { s: setops-sauvegarde.service.j2, d: setops-sauvegarde.service }
- { s: setops-sauvegarde.timer.j2, d: setops-sauvegarde.timer } - { s: setops-sauvegarde.timer.j2, d: setops-sauvegarde.timer }
- { s: setops-sauvegarde-avant-raser.service.j2, d: setops-sauvegarde-avant-raser.service }
- name: Activer le timer de sauvegarde - name: Activer le timer de sauvegarde
when: when:
@ -115,8 +127,10 @@
state: absent state: absent
loop: loop:
- /usr/local/sbin/setops-sauvegarder.sh - /usr/local/sbin/setops-sauvegarder.sh
- /usr/local/sbin/setops-copie-a-froid
- /etc/systemd/system/setops-sauvegarde.service - /etc/systemd/system/setops-sauvegarde.service
- /etc/systemd/system/setops-sauvegarde.timer - /etc/systemd/system/setops-sauvegarde.timer
- /etc/systemd/system/setops-sauvegarde-avant-raser.service
- /usr/local/sbin/setops-verifier-mon-depot.sh - /usr/local/sbin/setops-verifier-mon-depot.sh
- /usr/local/sbin/setops-verifier-restauration.sh - /usr/local/sbin/setops-verifier-restauration.sh
- /etc/systemd/system/setops-verification-depot.service - /etc/systemd/system/setops-verification-depot.service

View file

@ -28,12 +28,17 @@
# choisir [--instantane ID] (pour Ansible) JSON de l'instantane candidat # choisir [--instantane ID] (pour Ansible) JSON de l'instantane candidat
# fichiers [opts] CHEMIN... remettre des repertoires (rsync --delete) # fichiers [opts] CHEMIN... remettre des repertoires (rsync --delete)
# --proprietaire U:G --remplacer --vers DOSSIER (repetition, rien n'est touche) # --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) # annuaire [opts] BASE_DN remplacer la base LDAP (slapadd)
# --essai (slapadd -u a blanc, rien n'est touche) # --essai (slapadd -u a blanc, rien n'est touche)
# base [opts] NOM rejouer une base depuis le pg_dumpall # base [opts] NOM rejouer une base depuis le pg_dumpall
# --remplacer --vers AUTRE_NOM (repetition dans une base jetable) # --remplacer --vers AUTRE_NOM (repetition dans une base jetable)
# acter JEU ETAT [INSTANTANE] poser le marqueur (restaure, neuf, en_place, abandonne) # acter JEU ETAT [INSTANTANE] poser le marqueur (restaure, neuf, en_place, abandonne)
# garde (pour la sauvegarde) refuser si un etat attend # 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. # Options communes : --instantane ID.
set -euo pipefail set -euo pipefail
@ -52,6 +57,12 @@ CLE_HOTE="/etc/ssh/ssh_host_ed25519_key.pub"
LDIF="{{ client_backup_staging }}/openldap/annuaire.ldif" LDIF="{{ client_backup_staging }}/openldap/annuaire.ldif"
DUMP_PG="{{ client_backup_staging }}/postgresql/toutes-bases.sql" DUMP_PG="{{ client_backup_staging }}/postgresql/toutes-bases.sql"
JEUX=({% for j in client_backup_jobs %} "{{ j.nom }}"{% endfor %} ) 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; } dire() { printf '%s\n' "$*" >&2; }
mourir() { dire "setops-restaurer: $*"; exit 1; } mourir() { dire "setops-restaurer: $*"; exit 1; }
@ -94,6 +105,27 @@ if c:
' "$(naissance)" "${impose}" ' "$(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 a utiliser : impose, sinon le candidat. Vide = rien a restaurer.
INSTANTANE="" INSTANTANE=""
resoudre_instantane() { resoudre_instantane() {
@ -127,22 +159,28 @@ vide() { # vrai si le chemin est absent, ou un repertoire sans contenu
} }
cmd_fichiers() { cmd_fichiers() {
local proprio="" remplacer=0 vers="" chemins=() src dest n local proprio="" remplacer=0 vers="" depuis="" chemins=() src dest n
while (( $# )); do while (( $# )); do
case "$1" in case "$1" in
--proprietaire) proprio="$2"; shift 2 ;; --proprietaire) proprio="$2"; shift 2 ;;
--remplacer) remplacer=1; shift ;; --remplacer) remplacer=1; shift ;;
--vers) vers="$2"; shift 2 ;; --vers) vers="$2"; shift 2 ;;
--depuis) depuis="$2"; shift 2 ;;
--instantane) INSTANTANE="$2"; shift 2 ;; --instantane) INSTANTANE="$2"; shift 2 ;;
*) chemins+=("$1"); shift ;; *) chemins+=("$1"); shift ;;
esac esac
done done
[[ -n "${chemins[*]:-}" ]] || mourir "fichiers : aucun chemin" [[ -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 resoudre_instantane
if [[ -z "${INSTANTANE}" ]]; then echo "RIEN : aucune incarnation precedente."; return 0; fi 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 for c in "${chemins[@]}"; do
src="${TMP}${c}" src="${TMP}${depuis:-${c}}"
dest="${vers}${c}" dest="${vers}${c}"
if [[ ! -e "${src}" ]]; then echo "ABSENT de ${INSTANTANE} : ${c}"; continue; fi if [[ ! -e "${src}" ]]; then echo "ABSENT de ${INSTANTANE} : ${c}"; continue; fi
if ! vide "${dest}"; then if ! vide "${dest}"; then
@ -274,24 +312,79 @@ cmd_choisir() {
"${c%% *}" "$( [[ -n "${c}" ]] && printf '%s' "${c#* }" )" "$(naissance)" "${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() { cmd_etat() {
local c local c a ids id present
echo "hote : ${MOI}" echo "hote : ${MOI}"
echo "naissance : $(date -d "@$(naissance)" -Is)" echo "naissance : $(date -d "@$(naissance)" -Is)"
c=$(candidat "") || exit 2 c=$(candidat "") || exit 2
# (Pas d'apostrophe dans `${c:-...}` : entre guillemets doubles, bash l'y lit comme un # (Pas d'apostrophe dans `${c:-...}` : entre guillemets doubles, bash l'y lit comme un
# guillemet ouvrant, et tout le script devient illisible.) # guillemet ouvrant, et tout le script devient illisible.)
if [[ -z "${c}" ]]; then c="aucun (premiere vie, ou rien avant la naissance)"; fi 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 for j in "${JEUX[@]}"; do
if [[ -f "${MARQUES}/${j}" ]]; then 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 else
echo "jeu ${j} : EN ATTENTE (aucun marqueur)" echo "jeu ${j} : EN ATTENTE (aucun marqueur)"
fi fi
done 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() { cmd_garde() {
local attente=() c local attente=() c
for j in "${JEUX[@]}"; do for j in "${JEUX[@]}"; do
@ -319,5 +412,6 @@ case "${sous}" in
base) cmd_base "$@" ;; base) cmd_base "$@" ;;
acter) cmd_acter "$@" ;; acter) cmd_acter "$@" ;;
garde) cmd_garde ;; garde) cmd_garde ;;
temoins) cmd_temoins "$@" ;;
*) mourir "sous-commande inconnue : ${sous}" ;; *) mourir "sous-commande inconnue : ${sous}" ;;
esac esac

View file

@ -11,6 +11,18 @@ export RESTIC_PASSWORD_FILE="/etc/setops/restic.pass"
# instantane » ou « depot corrompu ». On attend le verrou au lieu de conclure. # instantane » ou « depot corrompu ». On attend le verrou au lieu de conclure.
restic() { command restic --retry-lock {{ client_backup_attente_verrou }} "$@"; } 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) # 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 # 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 # 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 echo "aucun chemin declare n'existe encore sur cet hote — rien a sauvegarder." >&2
exit 0 exit 0
fi fi
restic backup --host "{{ inventory_hostname }}" --tag setops "${PRESENTS[@]}" restic backup --host "{{ inventory_hostname }}" "${ETIQUETTES[@]}" "${PRESENTS[@]}"
# 3 bis. L'etiquette passe au dernier depot d'avant rasage : les precedents rentrent dans
# la retention ordinaire. APRES le depot, jamais avant : s'il echoue, l'ancien reste garde.
if (( AVANT_RASER )); then
ANCIENS=$(restic snapshots --host "{{ inventory_hostname }}" --tag "{{ client_backup_etiquette_avant_raser }}" --json \
| python3 -c 'import json, sys
import datetime, re
def t(x):
s = re.sub(r"(\.\d{6})\d+", r"\1", x["time"])
return datetime.datetime.fromisoformat(re.sub(r"Z$", "+00:00", s)).timestamp()
s = sorted(json.load(sys.stdin) or [], key=t)
print(" ".join(x["id"] for x in s[:-1]))')
if [[ -n "${ANCIENS}" ]]; then
read -r -a ANCIENS_IDS <<< "${ANCIENS}"
restic tag --remove "{{ client_backup_etiquette_avant_raser }}" "${ANCIENS_IDS[@]}"
fi
fi
# 4. Rétention. # 4. Rétention.
restic forget {{ client_backup_retention }} --prune restic forget {{ client_backup_retention }} --keep-tag "{{ client_backup_etiquette_avant_raser }}" --prune

View file

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

View file

@ -56,9 +56,17 @@ rapporter() { # $1=code $2=texte
maintenant=$(date +%s) maintenant=$(date +%s)
# LE PLUS RECENT DE TOUS LES GROUPES, PAS LE PREMIER (2026-10-08). `--latest 1` rend le
# dernier instantane de CHAQUE groupe — machine ET liste de chemins. Ajouter un jeu change la
# liste : deux groupes, et `d[0]` etait l'ancien, fige. Sur site-mon-01, des le lendemain de
# l'arrivee de prometheus, loki et icinga : « EN RETARD : 26 h » sur une sauvegarde de 2 h 30,
# et « PERIME » promis a 50 h. On prend donc le plus recent de tous.
json=$(restic snapshots --latest 1 --json 2>/dev/null) json=$(restic snapshots --latest 1 --json 2>/dev/null)
horodatage=$(printf '%s' "${json}" | python3 -c \ horodatage=$(printf '%s' "${json}" | python3 -c \
'import sys,json;d=json.load(sys.stdin);print(d[0]["time"] if d else "")' 2>/dev/null) 'import sys,json,re,datetime
d=json.load(sys.stdin) or []
t=lambda x: datetime.datetime.fromisoformat(re.sub(r"Z$","+00:00",re.sub(r"(\.\d{6})\d+",r"\1",x["time"]))).timestamp()
print(max(d,key=t)["time"] if d else "")' 2>/dev/null)
if [[ -z "${horodatage}" ]]; then if [[ -z "${horodatage}" ]]; then
# ON NE DISTINGUE PAS ICI « depot vide » de « depot injoignable », et c'est voulu : # ON NE DISTINGUE PAS ICI « depot vide » de « depot injoignable », et c'est voulu :
# dans les deux cas il n'y a PAS de sauvegarde exploitable, et c'est ce qui compte. # dans les deux cas il n'y a PAS de sauvegarde exploitable, et c'est ce qui compte.

View file

@ -20,3 +20,6 @@ client_backup_groupes_etat:
- serveur_nextcloud - serveur_nextcloud
- serveur_rspamd - serveur_rspamd
- serveur_web_dorsal - serveur_web_dorsal
- serveur_icinga
- serveur_prometheus
- serveur_loki

View file

@ -194,12 +194,17 @@
notify: Redemarrer alloy notify: Redemarrer alloy
# Le port d'ecoute est IMPOSE ici plutot que subi : voir defaults/main.yml. # Le port d'ecoute est IMPOSE ici plutot que subi : voir defaults/main.yml.
- name: Imposer le port de l interface locale d Alloy #
# AUCUN RAPPORT D'USAGE (mesure du 2026-10-10). Par defaut, Alloy envoie a
# stats.grafana.org la liste de ses composants actifs : relevé a la frontiere depuis
# CHAQUE machine des trois ecosystemes, ~70 connexions par jour et par locataire. Il
# passait par la sortie 443 generale du socle, qui n'existe plus.
- name: Imposer le port de l interface locale d Alloy, sans rapport d usage
ansible.builtin.lineinfile: ansible.builtin.lineinfile:
path: /etc/default/alloy path: /etc/default/alloy
regexp: "^CUSTOM_ARGS=" regexp: "^CUSTOM_ARGS="
line: >- line: >-
CUSTOM_ARGS="--server.http.listen-addr={{ client_journal_http_adresse }}:{{ client_journal_http_port }}" CUSTOM_ARGS="--server.http.listen-addr={{ client_journal_http_adresse }}:{{ client_journal_http_port }} --disable-reporting"
create: false create: false
notify: Redemarrer alloy notify: Redemarrer alloy

View file

@ -110,6 +110,11 @@
when: not (common_packages_desarmer_maj_auto | bool) when: not (common_packages_desarmer_maj_auto | bool)
failed_when: false # une unite absente sur cette image n'est pas une faute failed_when: false # une unite absente sur cette image n'est pas une faute
# AVANT TOUTE OPERATION APT : pas de rattrapage herite du gabarit (voir minuteurs.yml).
- name: Empecher le rattrapage des minuteurs apt herite du gabarit
ansible.builtin.include_tasks: minuteurs.yml
when: not (common_packages_desarmer_maj_auto | bool)
- name: Mettre à jour le cache APT - name: Mettre à jour le cache APT
ansible.builtin.apt: ansible.builtin.apt:
update_cache: true update_cache: true

View file

@ -0,0 +1,65 @@
---
# LE RATTRAPAGE HERITE DU GABARIT (2026-10-08).
#
# `apt-daily.timer` et `apt-daily-upgrade.timer` sont PERSISTANTS : systemd garde la date
# de leur dernier passage (`/var/lib/systemd/timers/stamp-*`) et rattrape un passage
# manque. Une VM clonee herite de ces traces — celle d'`apt-daily` datait du 2026-06-21,
# jour de fabrication du gabarit. Elle « rattrape » donc des son demarrage, avec un delai
# aleatoire jusqu'a 60 min : a la reconstruction de Technolibre, `apt-daily-upgrade` est
# parti cinq minutes apres la naissance d'`infra-pki-01`, en pleine serie d'operations apt
# du role. Il tenait le verrou du CACHE DES ARCHIVES, qu'`apt-get` n'attend jamais
# (`DPkg::Lock::Timeout` ne couvre que dpkg) :
#
# 'apt-get autoremove' failed: E: Could not get lock /var/cache/apt/archives/lock.
#
# Une trace plus ancienne que la naissance de la machine n'est pas la sienne : on la remet a
# l'heure, et on relance le minuteur, qui recalcule son prochain passage (le matin suivant)
# sans rattraper. Les mises a jour automatiques restent armees (decision du 2026-09-11) ;
# seul disparait un passage herite. Sur une machine en service, la trace est recente :
# rien ne change.
- name: Naissance de la machine (cle d'hote SSH, posee au premier demarrage)
ansible.builtin.stat:
path: /etc/ssh/ssh_host_ed25519_key.pub
register: common_packages_naissance
- name: Traces des minuteurs apt persistants
ansible.builtin.stat:
path: "/var/lib/systemd/timers/stamp-{{ item }}"
loop: "{{ common_packages_unites_maj_auto | select('match', '.*[.]timer$') | list }}"
register: common_packages_traces
- name: Remettre a l'heure une trace heritee du gabarit
ansible.builtin.file:
path: "{{ item.stat.path }}"
state: touch
mode: "0644"
loop: >-
{{ common_packages_traces.results | selectattr('stat.exists')
| selectattr('stat.mtime', 'lt', common_packages_naissance.stat.mtime | default(0))
| list }}
loop_control:
label: "{{ item.item }}"
register: common_packages_traces_remises
when: common_packages_naissance.stat.exists
# Relancer recalcule le prochain passage d'apres la trace neuve. Relancer un minuteur ne
# declenche pas sa cible.
- name: Relancer les minuteurs dont la trace a ete remise
ansible.builtin.systemd_service:
name: "{{ item.item.item }}"
state: restarted
loop: "{{ common_packages_traces_remises.results | default([]) | selectattr('changed') | list }}"
loop_control:
label: "{{ item.item.item }}"
# UN PASSAGE DEJA PARTI se termine avant nos operations apt, plutot que de se les disputer.
# `is-active` rend 0 si l'une au moins des unites tourne.
- name: Attendre la fin d'un passage apt automatique deja en cours
ansible.builtin.command:
argv: ["systemctl", "is-active", "--quiet", "apt-daily.service", "apt-daily-upgrade.service"]
register: common_packages_passage
changed_when: false
failed_when: false
until: common_packages_passage.rc != 0
retries: 30
delay: 10

View file

@ -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` # 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). # 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" 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]

View file

@ -7,25 +7,25 @@ table inet setops_filter {
chain input { chain input {
type filter hook input priority 0; policy {{ nftables_baseline_input_policy }}; type filter hook input priority 0; policy {{ nftables_baseline_input_policy }};
iif "lo" accept iif "lo" accept comment "socle: communications locales"
ct state established,related accept ct state established,related accept comment "socle: connexions etablies et associees"
ct state invalid drop ct state invalid drop comment "socle: paquets invalides"
{% if nftables_baseline_allow_icmp | bool %} {% if nftables_baseline_allow_icmp | bool %}
ip protocol icmp accept ip protocol icmp accept comment "socle: diagnostic et erreurs reseau IPv4"
ip6 nexthdr icmpv6 accept ip6 nexthdr icmpv6 accept comment "socle: diagnostic et erreurs reseau IPv6"
{% endif %} {% endif %}
{% if nftables_baseline_allow_ssh | bool %} {% if nftables_baseline_allow_ssh | bool %}
tcp dport {{ nftables_baseline_ssh_port }} accept tcp dport {{ nftables_baseline_ssh_port }} accept comment "administration: acces SSH du gabarit de repli"
{% endif %} {% endif %}
{% for port in nftables_baseline_extra_tcp_ports %} {% for port in nftables_baseline_extra_tcp_ports %}
tcp dport {{ port }} accept tcp dport {{ port }} accept comment "repli: port TCP supplementaire declare par l'instance"
{% endfor %} {% endfor %}
{% for port in nftables_baseline_extra_udp_ports %} {% for port in nftables_baseline_extra_udp_ports %}
udp dport {{ port }} accept udp dport {{ port }} accept comment "repli: port UDP supplementaire declare par l'instance"
{% endfor %} {% endfor %}
} }

View file

@ -33,10 +33,21 @@
# couvrent pas (AVERTISSEMENT). Un flux reel non declare serait coupe a la prochaine regle # couvrent pas (AVERTISSEMENT). Un flux reel non declare serait coupe a la prochaine regle
# posee : mieux vaut l'apprendre avant. # 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). # Contrat : une ligne, code 0/1/2 (docs/supervision-conception.md).
# `CONNECTIVITE_JSON=<fichier>` impose une autre liste : c'est le controle negatif. # `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' 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: try:
d = json.load(open(sys.argv[1], encoding="utf-8")) 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)) resultats = list(ex.map(essai, sorties))
coupes = [(f, r) for f, r in resultats if r not in ("ouvert", "refuse")] 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"] personne = [(f, r) for f, r in resultats if r == "refuse"]
ouverts = len(resultats) - len(coupes) - len(personne) 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]) \ 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 "") + (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: if coupes:
print(f"COUPE : {len(coupes)}/{len(resultats)} flux sortant(s) ne passent pas — {nomme(coupes)}. {perf}") print(f"COUPE : {len(coupes)}/{len(resultats)} flux sortant(s) ne passent pas — {nomme(coupes)}. {perf}")
sys.exit(2) sys.exit(2)
avert = [] avert = []
info = "" info = ""
if INTERDITS and not gw:
avert.append("passerelle par defaut introuvable — la garde de la passerelle ne s'exerce pas")
if personne: if personne:
avert.append(f"{len(personne)} flux refuse(s) — service absent, ou refus de la frontiere — " avert.append(f"{len(personne)} flux refuse(s) — service absent, ou refus de la frontiere — "
f"{nomme(personne)}") f"{nomme(personne)}")

View file

@ -19,9 +19,13 @@ quarante minutes sur la **première** machine.
| | | | | |
|---|---| |---|---|
| **dépôts apt** | ce rôle — un cache, servi à la flotte | | **dépôts apt** | ce rôle — un cache, servi à la flotte |
| **téléchargements directs** (Forgejo, Keycloak, Nextcloud, oauth2-proxy, collections) | le **cache du contrôleur** : il télécharge une fois et pousse par SSH | | **téléchargements directs** (Forgejo, Keycloak, Nextcloud et ses applications, oauth2-proxy) | ce rôle — le **dépôt de binaires** (`/var/lib/setops/artefacts-directs`, servi sous `/setops-binaires` sur le même port) |
| **ce que le runner installe** (roues Python, collections Ansible, `verrou-runner.yml`) | ce rôle — le même dépôt de binaires |
Deux mécanismes parce que ce sont deux problèmes : on ne sert pas un dépôt apt par `scp`. Les rôles consommateurs prennent ces fichiers au dépôt du site d'abord, vérifient leur somme
(ou leur signature), et ne sortent sur Internet qu'en repli, en le disant. Depuis le
2026-10-09, une reconstruction ne demande plus rien à PyPI, Galaxy ni à l'app store de
Nextcloud quand le dépôt est rempli.
## Ce qu'il ne couvre pas encore ## Ce qu'il ne couvre pas encore

View file

@ -199,6 +199,14 @@
label: "{{ item }}" label: "{{ item }}"
when: serveur_artefacts_directs_actif | bool when: serveur_artefacts_directs_actif | bool
# CE QUE LE RUNNER INSTALLE (2026-10-09) : ses roues Python et ses collections, verrouillees
# a l'octet dans `verrou-runner.yml`, a la racine du moteur. Lues, pas recopiees.
- name: Lire le verrou du runner (roues Python, collections Ansible)
ansible.builtin.include_vars:
file: "{{ role_path }}/../../verrou-runner.yml"
name: serveur_artefacts_verrou_runner
when: serveur_artefacts_directs_actif | bool
# `force: false` ET UN `dest` NOMME PAR LA VERSION : une publication ne change pas sous # `force: false` ET UN `dest` NOMME PAR LA VERSION : une publication ne change pas sous
# le meme nom, donc un fichier deja present est le bon. Une nouvelle version porte un # le meme nom, donc un fichier deja present est le bon. Une nouvelle version porte un
# autre nom et se telecharge d'elle-meme — le depot se met a jour en suivant les roles, # autre nom et se telecharge d'elle-meme — le depot se met a jour en suivant les roles,
@ -211,10 +219,18 @@
ansible.builtin.get_url: ansible.builtin.get_url:
url: "{{ item.url }}" url: "{{ item.url }}"
dest: "{{ serveur_artefacts_directs_dir }}/{{ item.nom }}" dest: "{{ serveur_artefacts_directs_dir }}/{{ item.nom }}"
checksum: "{{ ('sha256:' ~ item.sha256) if item.sha256 is defined else omit }}"
mode: "0644" mode: "0644"
force: false force: false
timeout: 60 timeout: 60
loop: "{{ serveur_artefacts_directs }}" # Les applications de Nextcloud (2026-10-08) : declarees par `serveur_nextcloud`, lues
# telles quelles — meme `nom`, meme `url`, rien de recopie ici.
# Et le verrou du runner (2026-10-09). Un artefact qui declare sa somme est verifie ici :
# le site ne tient pas un fichier altere en chemin.
loop: >-
{{ serveur_artefacts_directs + (serveur_nextcloud_apps | default([]))
+ (serveur_artefacts_verrou_runner.python | default([]))
+ (serveur_artefacts_verrou_runner.collections | default([])) }}
loop_control: loop_control:
label: "{{ item.nom }}" label: "{{ item.nom }}"
register: serveur_artefacts_directs_tires register: serveur_artefacts_directs_tires

View file

@ -62,9 +62,14 @@ for noeud in {{ serveur_backup_noeuds_attendus | sort | join(' ') }}; do
continue continue
fi fi
# Le plus recent de tous les groupes (machine ET liste de chemins), pas le premier : voir
# `client_backup/templates/verifier-mon-depot.sh.j2` (2026-10-08).
json=$(restic -r "${depot}" snapshots --latest 1 --json 2>/dev/null) json=$(restic -r "${depot}" snapshots --latest 1 --json 2>/dev/null)
horodatage=$(printf '%s' "${json}" | python3 -c \ horodatage=$(printf '%s' "${json}" | python3 -c \
'import sys,json;d=json.load(sys.stdin);print(d[0]["time"] if d else "")' 2>/dev/null) 'import sys,json,re,datetime
d=json.load(sys.stdin) or []
t=lambda x: datetime.datetime.fromisoformat(re.sub(r"Z$","+00:00",re.sub(r"(\.\d{6})\d+",r"\1",x["time"]))).timestamp()
print(max(d,key=t)["time"] if d else "")' 2>/dev/null)
if [[ -z "${horodatage}" ]]; then if [[ -z "${horodatage}" ]]; then
rapporter "${noeud}" 2 "AUCUN INSTANTANE dans ${depot} — depot present mais vide." rapporter "${noeud}" 2 "AUCUN INSTANTANE dans ${depot} — depot present mais vide."
continue continue

View file

@ -42,5 +42,6 @@ flux:
pair: externe pair: externe
chiffrement: tls-requis chiffrement: tls-requis
raison: >- raison: >-
Les dépôts tiers qui n'existent qu'en HTTPS (smallstep, Grafana, Icinga). Le cache Les dépôts tiers qui n'existent qu'en HTTPS (smallstep, Grafana, Icinga), et le
les relaie pour que la flotte n'ait pas à sortir elle-même. dépôt de binaires (roues PyPI, collections Galaxy, versions GitHub, Nextcloud,
Forgejo). Le site les relaie pour que la flotte n'ait pas à sortir elle-même.

View file

@ -3,6 +3,10 @@
# Surcharge de l'unité du paquet. Le `coolwsd.xml` livré (439 lignes commentées) reste # Surcharge de l'unité du paquet. Le `coolwsd.xml` livré (439 lignes commentées) reste
# intact : le remplacer par un gabarit maison obligerait à suivre son évolution à chaque # intact : le remplacer par un gabarit maison obligerait à suivre son évolution à chaque
# version amont, et ferait perdre les commentaires qui expliquent chaque réglage. # version amont, et ferait perdre les commentaires qui expliquent chaque réglage.
#
# AUCUNE VÉRIFICATION DE VERSION (mesure du 2026-10-10) : par défaut coolwsd interroge
# collabora.co.uk toutes les 10 heures, et le dit aux utilisateurs par une fenêtre. Les
# mises à jour arrivent par apt ; `fetch_update_check=0` coupe l'appel, la fenêtre suit.
[Service] [Service]
ExecStart= ExecStart=
ExecStart=/usr/bin/coolwsd --version --o:sys_template_path=/opt/cool/systemplate \ ExecStart=/usr/bin/coolwsd --version --o:sys_template_path=/opt/cool/systemplate \
@ -12,6 +16,8 @@ ExecStart=/usr/bin/coolwsd --version --o:sys_template_path=/opt/cool/systemplate
--o:ssl.termination={{ serveur_collabora_ssl_termination | bool | lower }} \ --o:ssl.termination={{ serveur_collabora_ssl_termination | bool | lower }} \
--o:server_name={{ serveur_collabora_server_name }} \ --o:server_name={{ serveur_collabora_server_name }} \
--o:storage.wopi.alias_group[0].host=https://{{ serveur_collabora_domaine_nextcloud }}:443 \ --o:storage.wopi.alias_group[0].host=https://{{ serveur_collabora_domaine_nextcloud }}:443 \
--o:fetch_update_check=0 \
--o:allow_update_popup=false \
--o:admin_console.enable={{ serveur_collabora_admin_actif | bool | lower }}{% if serveur_collabora_admin_actif | bool %} \ --o:admin_console.enable={{ serveur_collabora_admin_actif | bool | lower }}{% if serveur_collabora_admin_actif | bool %} \
--o:admin_console.username={{ serveur_collabora_admin_utilisateur }} \ --o:admin_console.username={{ serveur_collabora_admin_utilisateur }} \
--o:admin_console.password={{ serveur_collabora_admin_mot_de_passe }}{% endif %} --o:admin_console.password={{ serveur_collabora_admin_mot_de_passe }}{% endif %}

View file

@ -9,22 +9,39 @@ flux:
chiffrement: ssh chiffrement: ssh
raison: "Plan de gestion : administration et déploiement Ansible par SSH (inter-nœud ; l'accès depuis l'extérieur est filtré à l'OPNsense)." raison: "Plan de gestion : administration et déploiement Ansible par SSH (inter-nœud ; l'accès depuis l'extérieur est filtré à l'OPNsense)."
# --- Sortie générale de la flotte --- # --- Sortie apt directe : SEULEMENT SANS AMONT D'ARTEFACTS (2026-10-10) ---
# Portée par le socle parce qu'elle vaut pour TOUS les hôtes, sans exception. #
# Non déclarée, la frontière la bloquerait : son `block out` final est un vrai # C'ÉTAIT UNE SORTIE 80/443 VERS TOUT INTERNET, POUR TOUTE MACHINE. Sa raison, « dépôts
# default-deny, et la flotte perdrait ses mises à jour et son horloge en silence. # apt », n'était plus vraie chez les locataires : leurs 26 machines prennent TOUTES leurs
# sources en `http://` par le cache du site (`artefacts_amorcage`), aucune n'a de dépôt
# HTTPS. Mesuré à la frontière, elle ne servait plus qu'à ce qu'aucun rôle ne déclarait :
# télémétrie de Grafana, d'Alloy et de Loki, vérification de version de Collabora,
# cartes de rspamd, repère du génome. Les trois premiers sont coupés à la source ; les
# deux derniers ont désormais leur propre flux, dans leur rôle.
#
# ELLE NE RESTE QUE LÀ OÙ IL N'Y A AUCUN AMONT. Le site non plus ne la reçoit pas : ses
# machines visent son propre cache depuis le 2026-09-14 (`site_inventaire.py`,
# `artefacts_amorcage`), et le cache, lui, porte ses propres sorties 80/443
# (`serveur_cache_site`, `serveur_artefacts`). Seul un écosystème sans aucune source
# d'artefacts — un laboratoire isolé — prend encore ses paquets en direct.
- sens: egress - sens: egress
port: 443 port: 443
protocole: tcp protocole: tcp
pair: externe pair: externe
chiffrement: tls-requis chiffrement: tls-requis
raison: "Dépôts apt en HTTPS (Debian, Smallstep, Grafana, Icinga, Forgejo, Nextcloud) — sans quoi aucun correctif de sécurité n'entre." seulement_si: {variable: artefacts_amorcage, non_vide: false}
raison: >-
Dépôts apt en direct, pour un écosystème SANS aucune source d'artefacts. Le site
et ses locataires passent par le cache du site et ne la reçoivent pas.
- sens: egress - sens: egress
port: 80 port: 80
protocole: tcp protocole: tcp
pair: externe pair: externe
chiffrement: clair chiffrement: clair
raison: "Dépôts apt en clair et redirections HTTP des miroirs (l'intégrité vient de la signature des paquets, pas du transport)." seulement_si: {variable: artefacts_amorcage, non_vide: false}
raison: >-
Dépôts apt en clair et redirections des miroirs, pour un écosystème SANS amont
d'artefacts. L'intégrité vient de la signature des paquets, pas du transport.
# --- Decouverte de MTU de chemin (PMTUD) --- # --- Decouverte de MTU de chemin (PMTUD) ---
# L'overlay EVPN plafonne a 1450 : tout ce qui traverse la frontiere depend de ces # L'overlay EVPN plafonne a 1450 : tout ce qui traverse la frontiere depend de ces
# deux messages. Bloques, la connexion s'etablit, les petites requetes passent et les # deux messages. Bloques, la connexion s'etablit, les petites requetes passent et les

View file

@ -11,6 +11,17 @@ Environment=GF_SERVER_DOMAIN={{ serveur_grafana_hostname }}
Environment=GF_SERVER_ROOT_URL={{ serveur_grafana_root_url }} Environment=GF_SERVER_ROOT_URL={{ serveur_grafana_root_url }}
Environment=GF_SECURITY_ADMIN_PASSWORD={{ serveur_grafana_admin_password }} Environment=GF_SECURITY_ADMIN_PASSWORD={{ serveur_grafana_admin_password }}
Environment=GF_USERS_ALLOW_SIGN_UP=false Environment=GF_USERS_ALLOW_SIGN_UP=false
{# GRAFANA N'APPELLE PERSONNE (mesure du 2026-10-10). Par defaut il interroge grafana.com
(versions, greffons) et envoie ses compteurs d'usage a stats.grafana.org : ~124 et ~70
connexions par jour, depuis le site et depuis chaque locataire. Les avatars viennent de
gravatar.com, les nouvelles de grafana.com. Rien de tout cela ne sert une console
interne ; les mises a jour arrivent par apt, comme le reste. #}
Environment=GF_ANALYTICS_REPORTING_ENABLED=false
Environment=GF_ANALYTICS_CHECK_FOR_UPDATES=false
Environment=GF_ANALYTICS_CHECK_FOR_PLUGIN_UPDATES=false
Environment=GF_ANALYTICS_FEEDBACK_LINKS_ENABLED=false
Environment=GF_SECURITY_DISABLE_GRAVATAR=true
Environment=GF_NEWS_NEWS_FEED_ENABLED=false
{# LA SYNTHESE COMME PAGE D'ACCUEIL (2026-10-04) : a la connexion, la question « faut-il {# LA SYNTHESE COMME PAGE D'ACCUEIL (2026-10-04) : a la connexion, la question « faut-il
agir ? » plutot que la liste des tableaux. Le fichier est pose par ce role (tableau de agir ? » plutot que la liste des tableaux. Le fichier est pose par ce role (tableau de
synthese) ; sans lui, Grafana revient a son accueil par defaut, sans erreur. #} synthese) ; sans lui, Grafana revient a son accueil par defaut, sans erreur. #}

View file

@ -155,6 +155,41 @@
# (2026-08-13), `hostname -f` rend le nom complet et Icinga s'emet spontanement un # (2026-08-13), `hostname -f` rend le nom complet et Icinga s'emet spontanement un
# certificat CN et SAN = FQDN. Rien a forcer : il suffisait que la machine sache # certificat CN et SAN = FQDN. Rien a forcer : il suffisait que la machine sache
# comment elle s'appelle. # comment elle s'appelle.
# L'AC D'UNE INCARNATION PRECEDENTE REVIENT AVANT `api setup` (2026-10-07). Icinga 2 en
# derive l'environnement d'Icinga DB, donc l'identifiant de chaque hote, service et ligne
# d'historique. `api setup` la reutilise si elle est la (« Found CA, skipping and using the
# existing one », mesure sur mon-01) ; sinon il en fabrique une, et l'historique restaure
# par serveur_postgresql deviendrait orphelin. Aucun paquet n'en cree : c'est donc ICI.
- name: L'AC d'Icinga existe-t-elle deja ?
ansible.builtin.stat:
path: "{{ serveur_icinga_ca }}"
register: serveur_icinga_ca_presente
- name: Restauration — l'AC d'une incarnation precedente ?
ansible.builtin.include_role:
name: client_backup
tasks_from: restaurer.yml
vars:
client_backup_restaurer_jeu: serveur_icinga
client_backup_restaurer_vierge: "{{ not serveur_icinga_ca_presente.stat.exists }}"
- name: Restauration — remettre l'AC et l'environnement d'Icinga DB
changed_when: true
ansible.builtin.command:
argv: >-
{{ ['/usr/local/sbin/setops-restaurer', 'fichiers',
'--instantane', client_backup_restauration.instantane,
'--proprietaire', 'nagios:nagios'] + client_backup_restauration.chemins }}
when: client_backup_restauration.etat == 'a_restaurer'
- name: Restauration — acter l'AC remise
ansible.builtin.include_role:
name: client_backup
tasks_from: acter.yml
vars:
client_backup_acter_etat: restaure
when: client_backup_restauration.etat == 'a_restaurer'
- name: Configurer l'API Icinga 2 - name: Configurer l'API Icinga 2
ansible.builtin.command: ansible.builtin.command:
cmd: icinga2 api setup cmd: icinga2 api setup
@ -174,12 +209,20 @@
enabled: true enabled: true
state: started state: started
- name: Importer le schema Icinga DB dans PostgreSQL (une fois) # SUR UNE BASE VIERGE SEULEMENT (2026-10-07). Le marqueur vit sur mon-01 : une machine
# reconstruite ne l'a plus, alors que la base, elle, revient restauree par serveur_postgresql
# (historique compris). Rejouer le schema par-dessus y ajouterait une version et des erreurs
# « already exists » que psql taisait. La base dit elle-meme si elle a deja son schema.
- name: Importer le schema Icinga DB dans PostgreSQL (une fois, base vierge)
ansible.builtin.shell: ansible.builtin.shell:
cmd: >- cmd: >-
PGPASSWORD='{{ serveur_icinga_db_password }}' export PGPASSWORD='{{ serveur_icinga_db_password }}';
deja=$(psql -h {{ serveur_icinga_db_host }} -U {{ serveur_icinga_entree.proprietaire }}
-d {{ serveur_icinga_entree.base }} -tAX
-c "select to_regclass('public.icingadb_schema') is not null");
if [ "$deja" != t ]; then
psql -h {{ serveur_icinga_db_host }} -U {{ serveur_icinga_entree.proprietaire }} psql -h {{ serveur_icinga_db_host }} -U {{ serveur_icinga_entree.proprietaire }}
-d {{ serveur_icinga_entree.base }} -f {{ serveur_icinga_schema }} -d {{ serveur_icinga_entree.base }} -f {{ serveur_icinga_schema }}; fi
&& touch /etc/icingadb/.schema-imported && touch /etc/icingadb/.schema-imported
creates: /etc/icingadb/.schema-imported creates: /etc/icingadb/.schema-imported
no_log: true no_log: true

View file

@ -195,6 +195,56 @@
group: loki group: loki
mode: "0750" 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 --- # --- TLS : synchroniser le cert step_ca AVANT d'activer http_tls_config ---
- name: TLS — repertoire des certificats (loki) - name: TLS — repertoire des certificats (loki)
ansible.builtin.file: ansible.builtin.file:

View file

@ -39,3 +39,7 @@ compactor:
limits_config: limits_config:
retention_period: {{ serveur_loki_retention }} retention_period: {{ serveur_loki_retention }}
{# Pas de rapport d'usage a stats.grafana.org (actif par defaut, mesure du 2026-10-10). #}
analytics:
reporting_enabled: false

View file

@ -74,6 +74,31 @@ serveur_nextcloud_http_port: 80
# IP de l'edge → trusted_proxies (pour voir la vraie IP client, pas throttler l'edge). # IP de l'edge → trusted_proxies (pour voir la vraie IP client, pas throttler l'edge).
serveur_nextcloud_edge_ip: "{{ hostvars[(groups['serveur_nginx'] | default([]) | first) | default('')].ansible_host | default('') }}" serveur_nextcloud_edge_ip: "{{ hostvars[(groups['serveur_nginx'] | default([]) | first) | default('')].ansible_host | default('') }}"
# --- LES APPLICATIONS NE VIENNENT PLUS DE L'APP STORE (2026-10-08) -------------------
#
# `occ app:install` les tirait d'apps.nextcloud.com a chaque construction. Le 2026-10-08,
# l'app store servait son catalogue (11,7 Mo) a 77 ko/s — 18,8 Mo/s ailleurs depuis la meme
# machine : Nextcloud a abandonne, et la reconstruction de Technolibre s'est arretee. Le
# site tient desormais ces archives dans son depot de binaires, comme celle de Nextcloud
# (`serveur_artefacts`), et le role les installe sans l'app store.
#
# EPINGLEES, VERIFIEES DEUX FOIS : la somme de l'archive (ci-dessous), puis la signature de
# son code (`occ integrity:check-app`, contre l'autorite de signature de Nextcloud, hors
# ligne ; eprouve : une ligne ajoutee a un fichier donne INVALID_HASH). Versions relevees
# sur Chezlepro, ou elles tournent sur Nextcloud 34.0.4. `nom` et `url` portent la version
# en toutes lettres : `test_apps_nextcloud.py` verifie qu'elles disent la meme.
serveur_nextcloud_apps:
- id: user_oidc
version: "8.11.0"
nom: "nextcloud-app-user_oidc-8.11.0.tar.gz"
url: "https://github.com/nextcloud-releases/user_oidc/releases/download/v8.11.0/user_oidc-v8.11.0.tar.gz"
sha256: "887920504c2f0e0d055abd66225ee7b66d756f50fe5a3739779d6b2ce108633f"
- id: richdocuments
version: "11.1.2"
nom: "nextcloud-app-richdocuments-11.1.2.tar.gz"
url: "https://github.com/nextcloud-releases/richdocuments/releases/download/v11.1.2/richdocuments-v11.1.2.tar.gz"
sha256: "96766a94cf417a716e47ed89c6ff2411c54954caa916afcdddc4d9668dffb557"
# --- SSO OIDC (Keycloak) — app user_oidc --- # --- SSO OIDC (Keycloak) — app user_oidc ---
serveur_nextcloud_oidc_actif: true serveur_nextcloud_oidc_actif: true
serveur_nextcloud_oidc_nom: "{{ organisation | default('Organisation') }}" serveur_nextcloud_oidc_nom: "{{ organisation | default('Organisation') }}"

View file

@ -0,0 +1,125 @@
---
# UNE APPLICATION NEXTCLOUD, SANS L'APP STORE (2026-10-08) — voir `serveur_nextcloud_apps`.
#
# entree : serveur_nextcloud_app_id (user_oidc, richdocuments)
#
# Le depot du SITE d'abord, l'amont ensuite (meme patron que l'archive de Nextcloud,
# `installer.yml`) ; la somme verifiee dans les deux cas ; puis la signature du code ; puis
# l'activation. Une version installee qui differe de l'epinglee ARRETE : une mise a niveau
# d'application est une decision (et un `occ upgrade`), pas un effet de bord.
- name: "Application Nextcloud — l'application epinglee ({{ serveur_nextcloud_app_id }})"
ansible.builtin.set_fact:
serveur_nextcloud_app: >-
{{ serveur_nextcloud_apps | selectattr('id', 'equalto', serveur_nextcloud_app_id) | first }}
- name: "Application Nextcloud — deja presente ? ({{ serveur_nextcloud_app_id }})"
ansible.builtin.slurp:
src: "{{ serveur_nextcloud_racine }}/apps/{{ serveur_nextcloud_app.id }}/appinfo/info.xml"
register: serveur_nextcloud_app_info
failed_when: false
- name: "Application Nextcloud — la version presente est-elle l'epinglee ? ({{ serveur_nextcloud_app_id }})"
vars:
presente: >-
{{ (serveur_nextcloud_app_info.content | b64decode
| regex_search('<version>([^<]+)</version>', '\\1') | default([''], true) | first)
if serveur_nextcloud_app_info.content is defined else '' }}
ansible.builtin.assert:
that:
- presente | length == 0 or presente == serveur_nextcloud_app.version
fail_msg: >-
{{ serveur_nextcloud_app.id }} {{ presente }} est installee, {{ serveur_nextcloud_app.version }}
est epinglee. Une mise a niveau d'application se decide (et passe par `occ upgrade`) :
ajuster `serveur_nextcloud_apps`, ou mettre a niveau a la main.
quiet: true
- name: "Application Nextcloud — l'obtenir et la deposer ({{ serveur_nextcloud_app_id }})"
when: serveur_nextcloud_app_info.content is not defined
block:
- name: "Application Nextcloud — le cache du controleur ({{ serveur_nextcloud_app_id }})"
ansible.builtin.file:
path: "{{ serveur_nextcloud_cache_local }}"
state: directory
mode: "0700"
delegate_to: localhost
become: false
# `failed_when: false` : le depot est une COMMODITE ; sans lui, on retombe sur l'amont.
# La somme est verifiee ici aussi : un fichier altere chez le site ne passe pas.
- name: "Application Nextcloud — au depot du site, avant de sortir sur Internet ({{ serveur_nextcloud_app_id }})"
ansible.builtin.get_url:
url: "{{ setops_depot_binaires }}/{{ serveur_nextcloud_app.nom }}"
dest: "{{ serveur_nextcloud_cache_local }}/{{ serveur_nextcloud_app.nom }}"
checksum: "sha256:{{ serveur_nextcloud_app.sha256 }}"
mode: "0644"
timeout: 30
delegate_to: localhost
become: false
register: serveur_nextcloud_app_site
failed_when: false
when: setops_depot_binaires | default('') | length > 0
- name: "Application Nextcloud — dans le cache du controleur ? ({{ serveur_nextcloud_app_id }})"
ansible.builtin.stat:
path: "{{ serveur_nextcloud_cache_local }}/{{ serveur_nextcloud_app.nom }}"
checksum_algorithm: sha256
register: serveur_nextcloud_app_cache
delegate_to: localhost
become: false
# ON LE DIT, FORT : une reconstruction qui sort sur Internet doit se voir.
- name: "Application Nextcloud — absente du depot du site : prise en amont ({{ serveur_nextcloud_app_id }})"
ansible.builtin.debug:
msg: >-
{{ serveur_nextcloud_app.nom }} n'est pas au depot du site
({{ setops_depot_binaires | default('aucun depot declare') }}) : prise sur Internet.
when: not (serveur_nextcloud_app_cache.stat.exists
and serveur_nextcloud_app_cache.stat.checksum == serveur_nextcloud_app.sha256)
- name: "Application Nextcloud — en amont (repli) ({{ serveur_nextcloud_app_id }})"
ansible.builtin.get_url:
url: "{{ serveur_nextcloud_app.url }}"
dest: "{{ serveur_nextcloud_cache_local }}/{{ serveur_nextcloud_app.nom }}"
checksum: "sha256:{{ serveur_nextcloud_app.sha256 }}"
mode: "0644"
timeout: 60
delegate_to: localhost
become: false
when: not (serveur_nextcloud_app_cache.stat.exists
and serveur_nextcloud_app_cache.stat.checksum == serveur_nextcloud_app.sha256)
- name: "Application Nextcloud — deposer le code dans apps/ ({{ serveur_nextcloud_app_id }})"
ansible.builtin.unarchive:
src: "{{ serveur_nextcloud_cache_local }}/{{ serveur_nextcloud_app.nom }}"
dest: "{{ serveur_nextcloud_racine }}/apps"
owner: "{{ serveur_nextcloud_utilisateur }}"
group: "{{ serveur_nextcloud_utilisateur }}"
creates: "{{ serveur_nextcloud_racine }}/apps/{{ serveur_nextcloud_app.id }}/appinfo/info.xml"
- name: "Application Nextcloud — signature du code, puis activation ({{ serveur_nextcloud_app_id }})"
become: true
become_user: "{{ serveur_nextcloud_utilisateur }}"
environment:
PGSSLMODE: "{{ serveur_nextcloud_pg_sslmode }}"
PGSSLROOTCERT: "{{ serveur_nextcloud_ca_racine }}"
when: not ansible_check_mode
block:
# Contre l'autorite de signature de Nextcloud, sans reseau. Rien a dire = intact.
- name: "Application Nextcloud — le code est-il celui que Nextcloud a signe ? ({{ serveur_nextcloud_app_id }})"
ansible.builtin.command:
argv: ["php", "{{ serveur_nextcloud_racine }}/occ", "integrity:check-app", "{{ serveur_nextcloud_app.id }}"]
register: serveur_nextcloud_app_integrite
changed_when: false
failed_when: serveur_nextcloud_app_integrite.rc != 0 or serveur_nextcloud_app_integrite.stdout | trim | length > 0
- name: "Application Nextcloud — activer ({{ serveur_nextcloud_app_id }})"
ansible.builtin.shell:
executable: /bin/bash
cmd: |
set -eo pipefail
OCC="php {{ serveur_nextcloud_racine }}/occ"
APP="{{ serveur_nextcloud_app.id }}"
if $OCC app:list --enabled | grep -qw "$APP"; then echo SETOPS_OK
else $OCC app:enable "$APP" && echo SETOPS_CHANGED; fi
register: serveur_nextcloud_app_active
changed_when: "'SETOPS_CHANGED' in serveur_nextcloud_app_active.stdout"

View file

@ -1,4 +1,10 @@
--- ---
# L'APPLICATION VIENT DU DEPOT DU SITE, PLUS DE L'APP STORE (2026-10-08) : `app_tierce.yml`.
- name: Installer l'app richdocuments (depot du site, somme, signature, activation)
ansible.builtin.include_tasks: app_tierce.yml
vars:
serveur_nextcloud_app_id: richdocuments
- name: Collabora / Office (occ, en www-data avec TLS PG) - name: Collabora / Office (occ, en www-data avec TLS PG)
become: true become: true
become_user: "{{ serveur_nextcloud_utilisateur }}" become_user: "{{ serveur_nextcloud_utilisateur }}"
@ -7,16 +13,6 @@
PGSSLROOTCERT: "{{ serveur_nextcloud_ca_racine }}" PGSSLROOTCERT: "{{ serveur_nextcloud_ca_racine }}"
when: not ansible_check_mode when: not ansible_check_mode
block: block:
- name: Installer + activer l'app richdocuments (idempotent)
ansible.builtin.shell:
executable: /bin/bash
cmd: |
set -eo pipefail
OCC="php {{ serveur_nextcloud_racine }}/occ"
if $OCC app:list | grep -qw richdocuments; then echo SETOPS_OK; else $OCC app:install richdocuments && echo SETOPS_CHANGED; fi
register: serveur_nextcloud_office_app
changed_when: "'SETOPS_CHANGED' in serveur_nextcloud_office_app.stdout"
- name: URL serveur -> Collabora (locale, rapide) - name: URL serveur -> Collabora (locale, rapide)
ansible.builtin.command: ansible.builtin.command:
argv: ["php", "{{ serveur_nextcloud_racine }}/occ", "config:app:set", "richdocuments", "wopi_url", "--value={{ serveur_nextcloud_wopi_url }}"] argv: ["php", "{{ serveur_nextcloud_racine }}/occ", "config:app:set", "richdocuments", "wopi_url", "--value={{ serveur_nextcloud_wopi_url }}"]

View file

@ -1,4 +1,10 @@
--- ---
# L'APPLICATION VIENT DU DEPOT DU SITE, PLUS DE L'APP STORE (2026-10-08) : `app_tierce.yml`.
- name: Installer l'app user_oidc (depot du site, somme, signature, activation)
ansible.builtin.include_tasks: app_tierce.yml
vars:
serveur_nextcloud_app_id: user_oidc
- name: SSO OIDC (occ, en www-data avec TLS PG) - name: SSO OIDC (occ, en www-data avec TLS PG)
become: true become: true
become_user: "{{ serveur_nextcloud_utilisateur }}" become_user: "{{ serveur_nextcloud_utilisateur }}"
@ -7,16 +13,6 @@
PGSSLROOTCERT: "{{ serveur_nextcloud_ca_racine }}" PGSSLROOTCERT: "{{ serveur_nextcloud_ca_racine }}"
when: not ansible_check_mode when: not ansible_check_mode
block: block:
- name: Installer + activer l'app user_oidc (idempotent)
ansible.builtin.shell:
executable: /bin/bash
cmd: |
set -eo pipefail
OCC="php {{ serveur_nextcloud_racine }}/occ"
if $OCC app:list | grep -qw user_oidc; then echo SETOPS_OK; else $OCC app:install user_oidc && echo SETOPS_CHANGED; fi
register: serveur_nextcloud_oidc_app
changed_when: "'SETOPS_CHANGED' in serveur_nextcloud_oidc_app.stdout"
- name: Ne plus annoncer le formulaire de connexion locale - name: Ne plus annoncer le formulaire de connexion locale
# Masquage, pas fermeture : `?direct=1` reste le chemin de secours documente par # Masquage, pas fermeture : `?direct=1` reste le chemin de secours documente par
# l'amont. Voir serveur_nextcloud_connexion_locale dans defaults/main.yml. # l'amont. Voir serveur_nextcloud_connexion_locale dans defaults/main.yml.

View file

@ -30,7 +30,7 @@ serveur_ops_paquets:
# Ansible est ÉPINGLÉ sur la même famille que le poste du mainteneur (core 2.18) : un # Ansible est ÉPINGLÉ sur la même famille que le poste du mainteneur (core 2.18) : un
# écosystème qui se reconstruit avec une version différente de celle qui l'a construit ne # écosystème qui se reconstruit avec une version différente de celle qui l'a construit ne
# reproduit pas la même chose, et l'écart ne se voit qu'au premier échec. # reproduit pas la même chose, et l'écart ne se voit qu'au premier échec.
serveur_ops_ansible: "ansible-core>=2.18,<2.19" serveur_ops_ansible: "ansible-core==2.18.19"
# --- D'OÙ VIENT LE GÉNOME ---------------------------------------------------- # --- D'OÙ VIENT LE GÉNOME ----------------------------------------------------
# #

View file

@ -41,24 +41,26 @@ flux:
pair: flotte pair: flotte
chiffrement: ssh chiffrement: ssh
raison: "Piloter la flotte — c'est la raison d'être du poste." 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 # UN LOCATAIRE NE PARLE PAS A L'HYPERVISEUR — ET CE N'EST PLUS DECLARE ICI (2026-10-05).
# 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.
# #
# La forge DE TRAVAIL d'un ecosysteme -- celle qui sert ses gens -- reste un service du # Ce fichier portait une sortie 8006 vers `externe` : « API de l'hyperviseur : créer et
# tenant, et n'a rien a voir avec ce flux. # cloner les VM d'un écosystème descendant ». Mesure du 2026-10-05, depuis trois machines de
- sens: egress # deux locataires : AUCUN paquet ne passait. La frontiere rendait cette sortie vers « tout
port: 443 # sauf les plages privees », et les hyperviseurs sont en plages privees ; elle refusait donc
protocole: tcp # (journal : `block`, refus audible du lien de transit). La regle etait morte, et sa lecture
pair: voisins_site # mentait : elle disait qu'un locataire pouvait creer des VM.
chiffrement: tls-requis #
raison: "Cloner le genome depuis la forge du site, quand cet ecosysteme n'heberge pas la sienne." # 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.

View file

@ -108,80 +108,140 @@
become: false become: false
run_once: true run_once: true
# LE CONTROLEUR ET LA CIBLE DOIVENT PARTAGER LEUR PYTHON, et ca ne se devine pas. # LE VERROU DU RUNNER (2026-10-09) — `verrou-runner.yml`, a la racine du moteur.
# #
# `pip download` rend les roues du systeme qui telecharge. `ansible-core`, `proxmoxer` et # Les roues ne sont plus resolues par `pip download` contre PyPI, a chaque passage : elles
# `requests` sont universelles (`py3-none-any`), mais `pyyaml` porte du C compile : une # sont ECRITES, une par une, avec leur somme, et le SITE les tient dans son depot de
# roue `cp313` posee sur un python 3.12 ne s'installe pas. L'echec serait « No matching # binaires. Le runner de Technolibre portait deux versions de six bibliotheques — PyPI avait
# distribution found » a l'installation — un message qui accuse le depot local alors que # bouge entre l'insemination et le montage. Desormais, ce qui s'installe est ce que le
# le coupable est l'ecart entre deux machines. On le mesure ici, ou on peut encore le dire. # verrou nomme, et rien d'autre.
- name: Lire le Python du controleur - name: Lire le verrou du runner (roues Python, collections Ansible)
ansible.builtin.command: ansible.builtin.include_vars:
argv: ["python3", "-c", "import sys; print('%d.%d' % sys.version_info[:2])"] file: "{{ playbook_dir }}/../../verrou-runner.yml"
delegate_to: localhost name: serveur_ops_verrou
become: false
run_once: true
changed_when: false
# LIRE UNE VERSION N'EST PAS CHANGER, ET `--check` RENDAIT LA GARDE MUETTE (2026-09-14).
#
# Sans `check_mode: false`, cette commande est sautee en mode idempotent : la garde qui
# suit compare alors contre du VIDE et refuse.
#
# Le controleur tourne Python et site-ops-01 tourne Python 3.13
#
# L'espace apres « Python » est tout le diagnostic — il n'y avait rien a comparer. Un
# essai a blanc rendait donc un echec sur le runner, alors que le deploiement reel passe.
check_mode: false
register: serveur_ops_python_controleur
- name: Exiger que le controleur et la cible partagent leur version de Python # LA CIBLE DOIT PORTER LE PYTHON DES ROUES VERROUILLEES. `pyyaml`, `cffi`, `markupsafe`...
# portent du C compile pour un Python donne : une roue `cp313` posee sur un 3.12 ne
# s'installe pas, et l'echec accuserait le depot local. Le controleur, lui, ne compile plus
# rien : il ne fait que transporter des fichiers ecrits.
- name: Exiger que la cible porte le Python des roues verrouillees
ansible.builtin.assert: ansible.builtin.assert:
that: that:
- serveur_ops_python_controleur.stdout | trim - ansible_python_version.split('.')[0:2] | join('.') == serveur_ops_verrou.python_cible | string
== ansible_python_version.split('.')[0:2] | join('.')
fail_msg: >- fail_msg: >-
Le controleur tourne Python {{ serveur_ops_python_controleur.stdout | trim }} et {{ inventory_hostname }} tourne Python {{ ansible_python_version }} ; les roues de
{{ inventory_hostname }} tourne Python `verrou-runner.yml` sont faites pour Python {{ serveur_ops_verrou.python_cible }}.
{{ ansible_python_version.split('.')[0:2] | join('.') }}. Les roues telechargees ne Monter le verrou (nouvelles roues, nouvelles sommes), ou garder la cible sur ce Python.
s'installeraient pas sur la cible, et l'echec accuserait le depot local au lieu de
cet ecart. Materialiser depuis un controleur de meme famille — pendant une
insemination, c'est le runner du SITE, qui porte le meme Debian que la machine
qu'il amorce.
# `argv` ET NON `cmd` : le chemin du controleur peut contenir une ESPACE (« Espace # LE DEPOT DU SITE D'ABORD, L'AMONT ENSUITE — la somme verifiee dans les deux cas.
# Chezlepro/… » chez le mainteneur). Meme piege que pour ansible-galaxy le 2026-08-23. # `failed_when: false` : le depot est une COMMODITE ; sans lui, on retombe sur l'amont, et on
- name: Telecharger les roues sur le controleur (une seule fois par empreinte) # le dit (tache suivante).
ansible.builtin.command: - name: Prendre les roues verrouillees au depot du site
argv: ansible.builtin.get_url:
- "python3" url: "{{ setops_depot_binaires }}/{{ item.nom }}"
- "-m" dest: "{{ serveur_ops_cache_roues }}/{{ item.nom }}"
- "pip" checksum: "sha256:{{ item.sha256 }}"
- "download" mode: "0644"
- "--dest" timeout: 30
- "{{ serveur_ops_cache_roues }}" loop: "{{ serveur_ops_verrou.python }}"
- "--requirement" loop_control:
- "{{ serveur_ops_roues_source }}" label: "{{ item.nom }}"
- "{{ serveur_ops_ansible }}"
- "pyyaml"
delegate_to: localhost delegate_to: localhost
become: false become: false
run_once: true run_once: true
register: serveur_ops_roues_telechargees failed_when: false
changed_when: "'Saved' in serveur_ops_roues_telechargees.stdout" when:
# LE GARDE-FOU SUIT LE RESULTAT, PAS LE GESTE (lecon du 2026-08-27) : on saute quand le - setops_depot_binaires | default('') | length > 0
# cache PORTE deja des roues, pas quand une commande a deja tourne un jour. - not ansible_check_mode
when: not ansible_check_mode
- name: Deposer les roues sur le runner - name: Les roues verrouillees sont-elles dans le cache du controleur ?
ansible.builtin.stat:
path: "{{ serveur_ops_cache_roues }}/{{ item.nom }}"
checksum_algorithm: sha256
loop: "{{ serveur_ops_verrou.python }}"
loop_control:
label: "{{ item.nom }}"
delegate_to: localhost
become: false
run_once: true
register: serveur_ops_roues_cache
# ON LE DIT, FORT : une reconstruction qui sort sur Internet doit se voir.
- name: Dire les roues absentes du depot du site (prises en amont)
ansible.builtin.debug:
msg: >-
{{ serveur_ops_roues_cache.results | rejectattr('stat.exists') | map(attribute='item.nom') | list }}
— absentes du depot du site ({{ setops_depot_binaires | default('aucun depot declare') }}) : prises sur Internet.
run_once: true
# PAS EN SIMULATION : `--check` ne telecharge rien depuis le site, le cache reste vide et
# ce message accuserait le depot a tort (vu le 2026-10-09 sur le runner de Technolibre).
when:
- not ansible_check_mode
- serveur_ops_roues_cache.results | rejectattr('stat.exists') | list | length > 0
- name: Prendre en amont les roues que le site n'a pas (repli)
ansible.builtin.get_url:
url: "{{ item.item.url }}"
dest: "{{ serveur_ops_cache_roues }}/{{ item.item.nom }}"
checksum: "sha256:{{ item.item.sha256 }}"
mode: "0644"
timeout: 60
# ABSENTE OU ALTEREE : `pip --no-index` ne verifie pas les sommes, un fichier present mais
# different passerait. On le reprend, et `get_url` le verifie.
loop: "{{ serveur_ops_roues_cache.results }}"
loop_control:
label: "{{ item.item.nom }}"
delegate_to: localhost
become: false
run_once: true
when:
- not ansible_check_mode
- not item.stat.exists or item.stat.checksum != item.item.sha256
# LES ROUES DU VERROU, ET ELLES SEULES. Copier le cache entier deposait aussi les versions
# d'hier (deux `cryptography`, deux `urllib3`...) ; `--no-index` choisissait alors la plus
# haute, que personne n'avait choisie.
# LE DOSSIER D'ABORD (2026-10-09). L'ancienne copie du cache ENTIER (`src: dossier/`) creait
# sa destination ; une copie fichier par fichier ne la cree pas. Sur un runner neuf, la
# reconstruction de Technolibre s'est arretee la (« Destination directory … does not exist »)
# — invisible en simulant sur un runner ou le dossier existait deja.
- name: Assurer le depot des roues sur le runner
ansible.builtin.file:
path: "{{ serveur_ops_roues_depot }}"
state: directory
owner: "{{ serveur_ops_utilisateur }}"
group: "{{ serveur_ops_utilisateur }}"
mode: "0755"
- name: Deposer sur le runner les roues du verrou
ansible.builtin.copy: ansible.builtin.copy:
src: "{{ serveur_ops_cache_roues }}/" src: "{{ serveur_ops_cache_roues }}/{{ item.nom }}"
dest: "{{ serveur_ops_roues_depot }}/" dest: "{{ serveur_ops_roues_depot }}/{{ item.nom }}"
owner: "{{ serveur_ops_utilisateur }}" owner: "{{ serveur_ops_utilisateur }}"
group: "{{ serveur_ops_utilisateur }}" group: "{{ serveur_ops_utilisateur }}"
mode: "0644" mode: "0644"
directory_mode: "0755" loop: "{{ serveur_ops_verrou.python }}"
loop_control:
label: "{{ item.nom }}"
when: not ansible_check_mode when: not ansible_check_mode
- name: Relever les roues deposees sur le runner
ansible.builtin.find:
paths: "{{ serveur_ops_roues_depot }}"
patterns: "*.whl"
register: serveur_ops_roues_deposees
- name: Retirer du runner les roues que le verrou ne nomme pas
ansible.builtin.file:
path: "{{ item.path }}"
state: absent
loop: >-
{{ serveur_ops_roues_deposees.files
| rejectattr('path', 'search', '/(' ~ (serveur_ops_verrou.python | map(attribute='nom')
| map('regex_escape') | join('|')) ~ ')$') | list }}
loop_control:
label: "{{ item.path | basename }}"
# `--no-index` EST LE POINT, PAS UNE OPTIMISATION. Sans lui, pip resterait capable de # `--no-index` EST LE POINT, PAS UNE OPTIMISATION. Sans lui, pip resterait capable de
# sortir vers PyPI le jour ou le depot local serait incomplet — et la reussite dependrait # sortir vers PyPI le jour ou le depot local serait incomplet — et la reussite dependrait
# alors d'un flux que ce tenant n'a pas le droit d'avoir. Un repli silencieux vers # alors d'un flux que ce tenant n'a pas le droit d'avoir. Un repli silencieux vers
@ -336,48 +396,107 @@
become: false become: false
run_once: true run_once: true
# `argv` ET NON `cmd` : le chemin du controleur peut contenir une ESPACE (« Espace # LES COLLECTIONS DU VERROU (2026-10-09), meme chemin que les roues : le depot du site
# Chezlepro/… » chez le mainteneur), et `cmd` la lit comme un separateur d'arguments. # d'abord, Galaxy en repli dit, la somme verifiee. `collection download` sortait sur Galaxy
# Constate le 2026-08-23 : ansible-galaxy recevait « /home/…/Espace » puis # a chaque runner neuf.
# « Chezlepro/… » comme deux arguments, et refusait — « positional collection_name arg - name: Prendre les collections verrouillees au depot du site
# and --requirements-file are mutually exclusive ». Le message ne parlait pas du tout du ansible.builtin.get_url:
# vrai probleme. url: "{{ setops_depot_binaires }}/{{ item.nom }}"
- name: Telecharger les collections dans le cache du controleur (une seule fois) dest: "{{ serveur_ops_cache_collections }}/{{ item.nom }}"
ansible.builtin.command: checksum: "sha256:{{ item.sha256 }}"
argv: mode: "0644"
- "ansible-galaxy" timeout: 30
- "collection" loop: "{{ serveur_ops_verrou.collections }}"
- "download" loop_control:
- "-r" label: "{{ item.nom }}"
- "{{ serveur_ops_requirements_controleur }}"
- "-p"
- "{{ serveur_ops_cache_collections }}"
creates: "{{ serveur_ops_cache_collections }}/requirements.yml"
delegate_to: localhost delegate_to: localhost
become: false become: false
run_once: true run_once: true
when: not ansible_check_mode failed_when: false
when:
- setops_depot_binaires | default('') | length > 0
- not ansible_check_mode
- name: Deposer les collections sur le poste - name: Les collections verrouillees sont-elles dans le cache du controleur ?
ansible.builtin.stat:
path: "{{ serveur_ops_cache_collections }}/{{ item.nom }}"
checksum_algorithm: sha256
loop: "{{ serveur_ops_verrou.collections }}"
loop_control:
label: "{{ item.nom }}"
delegate_to: localhost
become: false
run_once: true
register: serveur_ops_collections_cache
- name: Dire les collections absentes du depot du site (prises sur Galaxy)
ansible.builtin.debug:
msg: >-
{{ serveur_ops_collections_cache.results | rejectattr('stat.exists') | map(attribute='item.nom') | list }}
— absentes du depot du site : prises sur Internet.
run_once: true
# PAS EN SIMULATION : `--check` ne telecharge rien depuis le site, le cache reste vide et
# ce message accuserait le depot a tort (vu le 2026-10-09 sur le runner de Technolibre).
when:
- not ansible_check_mode
- serveur_ops_collections_cache.results | rejectattr('stat.exists') | list | length > 0
- name: Prendre sur Galaxy les collections que le site n'a pas (repli)
ansible.builtin.get_url:
url: "{{ item.item.url }}"
dest: "{{ serveur_ops_cache_collections }}/{{ item.item.nom }}"
checksum: "sha256:{{ item.item.sha256 }}"
mode: "0644"
timeout: 60
loop: "{{ serveur_ops_collections_cache.results }}"
loop_control:
label: "{{ item.item.nom }}"
delegate_to: localhost
become: false
run_once: true
when:
- not ansible_check_mode
- not item.stat.exists or item.stat.checksum != item.item.sha256
- name: Assurer le depot des collections sur le runner
ansible.builtin.file:
path: "{{ serveur_ops_racine }}/.collections-hors-ligne"
state: directory
owner: "{{ serveur_ops_utilisateur }}"
group: "{{ serveur_ops_utilisateur }}"
mode: "0755"
- name: Deposer sur le poste les collections du verrou
register: serveur_ops_depot_collections register: serveur_ops_depot_collections
ansible.builtin.copy: ansible.builtin.copy:
src: "{{ serveur_ops_cache_collections }}/" src: "{{ serveur_ops_cache_collections }}/{{ item.nom }}"
dest: "{{ serveur_ops_racine }}/.collections-hors-ligne/" dest: "{{ serveur_ops_racine }}/.collections-hors-ligne/{{ item.nom }}"
owner: "{{ serveur_ops_utilisateur }}" owner: "{{ serveur_ops_utilisateur }}"
group: "{{ serveur_ops_utilisateur }}" group: "{{ serveur_ops_utilisateur }}"
mode: "0644" mode: "0644"
directory_mode: "0755" loop: "{{ serveur_ops_verrou.collections }}"
loop_control:
label: "{{ item.nom }}"
when: not ansible_check_mode
# Le meme format que celui qu'ecrivait `collection download` : des archives en RELATIF,
# que l'installation lit depuis ce dossier (`chdir`).
- name: Ecrire la liste hors ligne des collections du verrou
register: serveur_ops_depot_collections_liste
ansible.builtin.copy:
dest: "{{ serveur_ops_racine }}/.collections-hors-ligne/requirements.yml"
owner: "{{ serveur_ops_utilisateur }}"
group: "{{ serveur_ops_utilisateur }}"
mode: "0644"
content: |
# Gere par Set-OPS (role serveur_ops), d'apres verrou-runner.yml. Ne pas editer.
collections:
{% for c in serveur_ops_verrou.collections %}
- name: {{ c.nom }}
version: {{ (c.nom | replace('.tar.gz', '')).split('-') | last }}
{% endfor %}
when: not ansible_check_mode when: not ansible_check_mode
# `chdir` OBLIGATOIRE : le `requirements.yml` produit par `collection download` nomme les
# archives en RELATIF (`community-postgresql-4.2.0.tar.gz`), et ansible-galaxy les cherche
# depuis le repertoire COURANT, pas depuis celui du fichier. Sans chdir : « Could not find
# community-postgresql-4.2.0.tar.gz » alors que l'archive est bien la, a cote.
# LE GARDE-FOU DOIT SUIVRE LE RESULTAT, PAS LE GESTE (2026-08-27). La condition
# d'installation ne regardait que le DEPOT des archives. Le jour ou l'on a corrige le
# CHEMIN d'installation, les archives etaient inchangees : le role a donc conclu qu'il
# n'y avait rien a faire, et les collections sont restees a l'ancien endroit. Le meme
# piege qu'en 2026-08-24, deplace d'un cran. On mesure desormais la DESTINATION.
- name: Voir si les collections sont deja la ou le moteur les cherche - name: Voir si les collections sont deja la ou le moteur les cherche
ansible.builtin.stat: ansible.builtin.stat:
path: >- path: >-
@ -431,6 +550,7 @@
# d'autre ne l'aurait vu. Les conditions vont donc dans UNE liste. # d'autre ne l'aurait vu. Les conditions vont donc dans UNE liste.
when: when:
- serveur_ops_depot_collections is changed - serveur_ops_depot_collections is changed
or serveur_ops_depot_collections_liste is changed
or not serveur_ops_collections_en_place.stat.exists or not serveur_ops_collections_en_place.stat.exists
- not ansible_check_mode - not ansible_check_mode

View file

@ -70,3 +70,16 @@ flux:
Insemination : amorcer le runner d'un tenant — socle, moteur, plan, plancher de 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 resolution — pour qu'il prenne ensuite le relais sur ses propres machines. Ne
transporte aucun secret : la voute est remise par un humain. transporte aucun secret : la voute est remise par un humain.
# LE REPERE DU GENOME (2026-10-10). La sonde `sonde-genome-a-jour` compare la forge du
# site a eregion par un `git ls-remote` anonyme, toutes les 15 minutes. Elle sortait par
# la sortie 443 generale du socle, qui n'existe plus : la declarer ici la rend lisible.
# Sans Internet, la sonde passe en INCONNU (code 3) — rien ne casse.
- sens: egress
port: 443
protocole: tcp
pair: externe
chiffrement: tls-requis
raison: >-
Repere du genome : `git ls-remote` anonyme sur eregion (forge.alliance-boreale.ca)
pour dire si la forge du site a pris du retard. Lecture seule, sans identifiant.

View file

@ -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, # configuration vont ensemble, et le code de ses applications (user_oidc,
# richdocuments) n'est pas sauvegarde : restauree avant son installation, la base # richdocuments) n'est pas sauvegarde : restauree avant son installation, la base
# les dirait installees sans qu'elles le soient, et `occ app:install` refuserait ; # 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: serveur_postgresql_restauration_consommateurs_exclus:
- nextcloud - nextcloud
- icinga
serveur_postgresql_bases_restaurables: >- serveur_postgresql_bases_restaurables: >-
{{ ((serveur_postgresql_registre | default([])) {{ ((serveur_postgresql_registre | default([]))

View file

@ -4,6 +4,9 @@ serveur_prometheus_paquets:
serveur_prometheus_service: "prometheus" serveur_prometheus_service: "prometheus"
serveur_prometheus_config: "/etc/prometheus/prometheus.yml" 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_intervalle: "15s"
serveur_prometheus_retention: "15d" serveur_prometheus_retention: "15d"

View file

@ -6,6 +6,58 @@
update_cache: true update_cache: true
cache_valid_time: 3600 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) ---------------------------------- # --- LES CIBLES QUE LES ROLES DECLARENT (2026-09-14) ----------------------------------
# #
# `serveur_prometheus_cibles_supplementaires` existait depuis longtemps — une liste libre, # `serveur_prometheus_cibles_supplementaires` existait depuis longtemps — une liste libre,

View file

@ -13,3 +13,32 @@ flux:
pair: localhost pair: localhost
chiffrement: clair chiffrement: clair
raison: "Interface de contrôle rspamd (statistiques, apprentissage) en local." raison: "Interface de contrôle rspamd (statistiques, apprentissage) en local."
# LES CARTES DE RSPAMD VIENNENT D'INTERNET, ET C'EST UN CHOIX (2026-10-10).
#
# Jusqu'ici rien ne le disait : rspamd sortait par la sortie 80/443 que le socle donnait
# a TOUTE machine. Mesure a la frontiere : ~2 700 connexions par jour et par locataire
# vers maps.rspamd.com, ~245 vers sa-update.surbl.org, depuis `edge-mta-01` seulement.
#
# Sans elles, rspamd ne tombe pas : chaque carte distante a son repli local
# (`file:///etc/rspamd/maps.d/…`), fige a la version du paquet. Les listes de messageries
# gratuites, de redirecteurs, d'hameconnage et de listes blanches DMARC vieilliraient en
# silence. On garde donc la sortie — mais declaree, et pour ce seul role, comme le 25
# sortant de Postfix.
- sens: egress
port: 443
protocole: tcp
pair: externe
chiffrement: tls-requis
raison: >-
Cartes de rspamd (maps.rspamd.com) : messageries gratuites et jetables, redirecteurs,
listes blanches DMARC/SPF/DKIM, hameconnage. Sans elles l'antispam garde les copies
du paquet, qui vieillissent.
- sens: egress
port: 80
protocole: tcp
pair: externe
chiffrement: clair
raison: >-
Carte de hachages SURBL (sa-update.surbl.org), publiee en clair par l'amont. Une
carte alteree ne peut que mal classer un courriel ; elle n'execute rien.

View file

@ -40,6 +40,7 @@ sys.path.insert(0, str(RACINE / "scripts"))
import devis_opnsense as devis_mod # noqa: E402 import devis_opnsense as devis_mod # noqa: E402
from proxmox_api import voute as voute_proxmox # noqa: E402 from proxmox_api import voute as voute_proxmox # noqa: E402
from resoudre_flux import borner_libelle, libelle_flux # noqa: E402
def _voute(base: Path) -> dict: def _voute(base: Path) -> dict:
@ -145,6 +146,13 @@ def cle_redirection(d: dict) -> str:
f"->{d['cible']}:{d['port_local']}") f"->{d['cible']}:{d['port_local']}")
def _description(cle: str, role: str, raison: str) -> str:
"""Garde l'identite complete en tete, puis le sens du flux (255 octets au total)."""
prefixe = cle + " — "
return prefixe + borner_libelle(libelle_flux(role, raison),
255 - len(prefixe.encode("utf-8")))
def _corps_redirection(d: dict, k: str) -> dict: def _corps_redirection(d: dict, k: str) -> dict:
"""Une redirection d'OPNsense 26.7 (`d_nat`), eprouvee le 2026-09-16. """Une redirection d'OPNsense 26.7 (`d_nat`), eprouvee le 2026-09-16.
@ -166,7 +174,8 @@ def _corps_redirection(d: dict, k: str) -> dict:
"natreflection": "disable", "natreflection": "disable",
"pass": "", "pass": "",
"log": "0", "log": "0",
"descr": f"{k} — {d.get('role', '')}"[:255], "descr": _description(k, str(d.get("role") or "publication"),
str(d.get("raison") or "Publication du service vers sa cible interne")),
} }
@ -232,7 +241,8 @@ def _corps_nat(n: dict, k: str) -> dict:
"source_net": n["source"], "source_net": n["source"],
"destination_net": n["destination"], "destination_net": n["destination"],
"target": n["cible"], "target": n["cible"],
"description": f"{k} — sortie tenant", "description": _description(k, str(n["tenant"]),
"Traduction des adresses en sortie"),
} }
@ -268,7 +278,7 @@ def _corps_regle(r: dict, k: str) -> dict:
"source_net": r["source"], "source_net": r["source"],
"destination_net": dst[1:] if nie else dst, "destination_net": dst[1:] if nie else dst,
"destination_not": "1" if nie else "0", "destination_not": "1" if nie else "0",
"description": f"{k} — {r['role']}", "description": _description(k, str(r["role"]), str(r["raison"])),
} }
if r["protocole"] in ("tcp", "udp") and r["ports"]: if r["protocole"] in ("tcp", "udp") and r["ports"]:
corps["destination_port"] = ",".join(map(str, r["ports"])) corps["destination_port"] = ",".join(map(str, r["ports"]))
@ -391,6 +401,20 @@ def plan(api: Frontiere, devis: dict) -> dict:
if d.startswith("setopsrdr:"): if d.startswith("setopsrdr:"):
rdr_posees[d.split(" — ")[0]] = x rdr_posees[d.split(" — ")[0]] = x
# Les identifiants restent stables : corriger une justification se fait par UUID,
# sans retirer/recreer la regle ni modifier son filtrage ou son activation.
descriptions_majer = []
for objet, demandes, existantes, rendre, champ in (
("filter", voulues, posees, _corps_regle, "description"),
("source_nat", nat_voulus, nat_poses, _corps_nat, "description"),
("d_nat", rdr_voulues, rdr_posees, _corps_redirection, "descr")):
for k in sorted(demandes.keys() & existantes.keys()):
attendu = rendre(demandes[k], k)[champ]
if str(existantes[k].get(champ) or existantes[k].get("description") or "") != attendu:
descriptions_majer.append({"objet": objet, "cle": k,
"uuid": existantes[k]["uuid"],
"champ": champ, "valeur": attendu})
rt_voulues = {cle_route(r): r for r in devis.get("routes") or []} rt_voulues = {cle_route(r): r for r in devis.get("routes") or []}
rt_posees = {} rt_posees = {}
for x in (_lire(api, "/api/routes/routes/searchroute/", for x in (_lire(api, "/api/routes/routes/searchroute/",
@ -403,6 +427,7 @@ def plan(api: Frontiere, devis: dict) -> dict:
# et transporte dans le plan pour que l'affichage puisse le dire. # et transporte dans le plan pour que l'affichage puisse le dire.
_reelles = routes_du_noyau(api) _reelles = routes_du_noyau(api)
return { return {
"descriptions_majer": descriptions_majer,
"routes_noyau_lisible": _reelles is not None, "routes_noyau_lisible": _reelles is not None,
# Presente dans le modele mais ETEINTE : elle compte « posee » partout ailleurs, # Presente dans le modele mais ETEINTE : elle compte « posee » partout ailleurs,
# et n'est installee nulle part. C'est la forme exacte du defaut du 2026-09-02. # et n'est installee nulle part. C'est la forme exacte du defaut du 2026-09-02.
@ -442,6 +467,8 @@ def afficher(p: dict) -> bool:
for k, r in sorted(p["regles_creer"].items()): for k, r in sorted(p["regles_creer"].items()):
print(f" + regle {r['interface']:<5} {r['protocole']:<4} " print(f" + regle {r['interface']:<5} {r['protocole']:<4} "
f"{str(r['ports'] or ''):<9} {r['source'][:30]:<30} -> {r['destination'][:24]}") f"{str(r['ports'] or ''):<9} {r['source'][:30]:<30} -> {r['destination'][:24]}")
for d in p["descriptions_majer"]:
print(f" ~ description {d['valeur']}")
for k, n in sorted(p["nat_creer"].items()): for k, n in sorted(p["nat_creer"].items()):
print(f" + NAT sortant {n['interface']:<5} {n['source'][:30]:<30} " print(f" + NAT sortant {n['interface']:<5} {n['source'][:30]:<30} "
f"-> {n['cible']}") f"-> {n['cible']}")
@ -495,9 +522,11 @@ def afficher(p: dict) -> bool:
retirer = len(p["regles_retirer"]) + len(p["alias_retirer"]) + len(p["nat_retirer"]) \ retirer = len(p["regles_retirer"]) + len(p["alias_retirer"]) + len(p["nat_retirer"]) \
+ len(p["routes_retirer"]) + len(p["rdr_retirer"]) + len(p["routes_retirer"]) + len(p["rdr_retirer"])
print(f"\n a creer : {creer} | a retirer : {retirer}" print(f"\n a creer : {creer} | a retirer : {retirer}"
f" | inchange : {len(p['regles_garder']) + len(p['nat_garder']) + len(p['rdr_garder'])}" f" | descriptions a corriger : {len(p['descriptions_majer'])}"
f" | inchange : {len(p['regles_garder']) + len(p['nat_garder']) + len(p['rdr_garder']) - len(p['descriptions_majer'])}"
f" + {len(p['routes_garder'])} routes") f" + {len(p['routes_garder'])} routes")
return any(p[c] for c in ("alias_creer", "alias_majer", "alias_retirer", return any(p[c] for c in ("alias_creer", "alias_majer", "alias_retirer",
"descriptions_majer",
"regles_creer", "regles_retirer", "regles_creer", "regles_retirer",
"nat_creer", "nat_retirer", "rdr_creer", "rdr_retirer", "nat_creer", "nat_retirer", "rdr_creer", "rdr_retirer",
"routes_creer", "routes_retirer")) "routes_creer", "routes_retirer"))
@ -541,6 +570,15 @@ def appliquer(api: Frontiere, p: dict) -> int:
_fait(api("/api/firewall/d_nat/add_rule/", {"rule": _corps_redirection(d, k)}), _fait(api("/api/firewall/d_nat/add_rule/", {"rule": _corps_redirection(d, k)}),
f"redirection {k}") f"redirection {k}")
for d in p["descriptions_majer"]:
chemin = f"/api/firewall/{d['objet']}"
if _fait(api(f"{chemin}/set_rule/{d['uuid']}",
{"rule": {d["champ"]: d["valeur"]}}), f"description {d['cle']}"):
relu = _lire(api, f"{chemin}/get_rule/{d['uuid']}").get("rule") or {}
if relu.get(d["champ"]) != d["valeur"]:
echecs += 1
print(f" ! ECHEC description {d['cle']} : relecture differente de l'ecriture")
# 2bis. Routes, meme ordre et pour une raison plus forte encore : une route # 2bis. Routes, meme ordre et pour une raison plus forte encore : une route
# manquante coupe la flotte, une route en trop ne fait qu'acheminer vers un # manquante coupe la flotte, une route en trop ne fait qu'acheminer vers un
# VRF qui la jettera. En cas de doute, on reste large. # VRF qui la jettera. En cas de doute, on reste large.

View file

@ -49,6 +49,7 @@ RACINE = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(RACINE / "scripts")) sys.path.insert(0, str(RACINE / "scripts"))
from proxmox_api import Cluster # noqa: E402 from proxmox_api import Cluster # noqa: E402
from resoudre_flux import libelle_flux # noqa: E402
def _prefixe(bloc: dict) -> str: def _prefixe(bloc: dict) -> str:
@ -90,6 +91,17 @@ def _regle_cle(r: dict) -> tuple:
str(r.get("icmp_type") or r.get("icmp-type") or "")) str(r.get("icmp_type") or r.get("icmp-type") or ""))
def _commentaire_regle(groupe: dict, regle: dict) -> str:
role = str(groupe.get("role") or groupe["nom"])
if regle.get("via"):
role += f" / {regle['via']}"
return libelle_flux(role, str(regle["raison"]))
def _commentaire_affectation(hote: str, groupe: dict) -> str:
return libelle_flux(hote, f"Appliquer les flux de {groupe.get('role') or groupe['nom']}")
def _prefixes_retires() -> tuple[str, ...]: def _prefixes_retires() -> tuple[str, ...]:
"""Les etiquettes des tenants RETIRES : `t29-` pour la zone `t29`. """Les etiquettes des tenants RETIRES : `t29-` pour la zone `t29`.
@ -162,6 +174,7 @@ def plan(api: Cluster, devis: dict) -> dict:
o = api(f"/nodes/{noeud}/qemu/{vmid}/firewall/options") or {} o = api(f"/nodes/{noeud}/qemu/{vmid}/firewall/options") or {}
affect_posees[vmid] = { affect_posees[vmid] = {
"noeud": noeud, "noeud": noeud,
"regles": [r for r in regles if isinstance(r, dict) and r.get("type") == "group"],
"groupes": sorted(str(r.get("action")) for r in regles "groupes": sorted(str(r.get("action")) for r in regles
if isinstance(r, dict) and r.get("type") == "group"), if isinstance(r, dict) and r.get("type") == "group"),
"actif": str(o.get("enable", "")), "actif": str(o.get("enable", "")),
@ -170,6 +183,35 @@ def plan(api: Cluster, devis: dict) -> dict:
"politique": str(o.get("policy_in", "")).upper(), "politique": str(o.get("policy_in", "")).upper(),
} }
# Une justification changee ne change pas la politique. Mettre le commentaire a
# jour EN PLACE : purger le groupe pour ce seul motif couperait ses autorisations.
regles_commenter = {}
for nom, groupe in groupes_voulus.items():
actuelles = groupes_poses.get(nom)
voulues = {_regle_cle(r): _commentaire_regle(groupe, r) for r in groupe["regles"]}
if actuelles is None or set(voulues) != {_regle_cle(r) for r in actuelles}:
continue # groupe absent ou politique differente : traitement habituel
changements = [{"pos": r["pos"], "comment": voulues[_regle_cle(r)]}
for r in actuelles
if str(r.get("comment") or "") != voulues[_regle_cle(r)]]
if changements:
regles_commenter[nom] = changements
affect_commenter = {}
for vmid, affect in affect_voulues.items():
posee = affect_posees.get(vmid)
if not posee:
continue
changements = []
for r in posee["regles"]:
nom = str(r.get("action"))
if nom in affect["groupes"]:
commentaire = _commentaire_affectation(affect["hote"], groupes_voulus[nom])
if str(r.get("comment") or "") != commentaire:
changements.append({"pos": r["pos"], "comment": commentaire})
if changements:
affect_commenter[vmid] = changements
opts = api("/cluster/firewall/options") or {} opts = api("/cluster/firewall/options") or {}
return { return {
"ipsets_creer": {n: v for n, v in ipsets_voulus.items() if n not in ipsets_poses}, "ipsets_creer": {n: v for n, v in ipsets_voulus.items() if n not in ipsets_poses},
@ -182,6 +224,8 @@ def plan(api: Cluster, devis: dict) -> dict:
and {_regle_cle(r) for r in g["regles"]} and {_regle_cle(r) for r in g["regles"]}
!= {_regle_cle(r) for r in groupes_poses[n]}}, != {_regle_cle(r) for r in groupes_poses[n]}},
"groupes_retirer": sorted(n for n in groupes_poses if n not in groupes_voulus), "groupes_retirer": sorted(n for n in groupes_poses if n not in groupes_voulus),
"regles_commenter": regles_commenter,
"affect_commenter": affect_commenter,
"affect_majer": {v: a for v, a in affect_voulues.items() "affect_majer": {v: a for v, a in affect_voulues.items()
if v in affect_posees if v in affect_posees
and (affect_posees[v]["groupes"] != a["groupes"] and (affect_posees[v]["groupes"] != a["groupes"]
@ -207,6 +251,11 @@ def afficher(p: dict) -> bool:
print(f" ~ groupe {n:<22} regles differentes") print(f" ~ groupe {n:<22} regles differentes")
for n in p["groupes_retirer"]: for n in p["groupes_retirer"]:
print(f" - groupe PERIME {n}") print(f" - groupe PERIME {n}")
for n, regles in sorted(p["regles_commenter"].items()):
print(f" ~ commentaires {n:<22} {len(regles)} regle(s), politique inchangee")
for v, regles in sorted(p["affect_commenter"].items()):
if v not in p["affect_majer"]:
print(f" ~ commentaires VM {v:<22} {len(regles)} affectation(s)")
for v, a in sorted(p["affect_majer"].items()): for v, a in sorted(p["affect_majer"].items()):
print(f" ~ VM {v:<12} {a['hote']:<16} -> {', '.join(a['groupes'])}") print(f" ~ VM {v:<12} {a['hote']:<16} -> {', '.join(a['groupes'])}")
if p["vm_absentes"]: if p["vm_absentes"]:
@ -230,6 +279,7 @@ def afficher(p: dict) -> bool:
print(" le reste du parc. La remettre a ACCEPT.") print(" le reste du parc. La remettre a ACCEPT.")
creer = len(p["ipsets_creer"]) + len(p["groupes_creer"]) creer = len(p["ipsets_creer"]) + len(p["groupes_creer"])
majer = len(p["ipsets_majer"]) + len(p["groupes_majer"]) + len(p["affect_majer"]) majer = len(p["ipsets_majer"]) + len(p["groupes_majer"]) + len(p["affect_majer"])
majer += len(p["regles_commenter"]) + len(set(p["affect_commenter"]) - set(p["affect_majer"]))
retirer = len(p["ipsets_retirer"]) + len(p["groupes_retirer"]) retirer = len(p["ipsets_retirer"]) + len(p["groupes_retirer"])
print(f"\n a creer : {creer} | a mettre a jour : {majer} | a retirer : {retirer}") print(f"\n a creer : {creer} | a mettre a jour : {majer} | a retirer : {retirer}")
return bool(creer or majer or retirer) return bool(creer or majer or retirer)
@ -269,13 +319,21 @@ def appliquer(api: Cluster, p: dict) -> int:
{"group": n, "comment": str(g.get("role", ""))[:100]}), f"groupe {n}") {"group": n, "comment": str(g.get("role", ""))[:100]}), f"groupe {n}")
for r in g["regles"]: for r in g["regles"]:
corps = {"type": str(r.get("sens", "in")).lower(), "action": r["action"], corps = {"type": str(r.get("sens", "in")).lower(), "action": r["action"],
"enable": 1, "comment": str(r.get("via") or g.get("role", ""))[:100]} "enable": 1, "comment": _commentaire_regle(g, r)}
for cle, champ in (("source", "source"), ("proto", "proto"), ("dport", "dport"), for cle, champ in (("source", "source"), ("proto", "proto"), ("dport", "dport"),
("icmp_type", "icmp-type")): ("icmp_type", "icmp-type")):
if r.get(cle): if r.get(cle):
corps[champ] = r[cle] corps[champ] = r[cle]
_fait(api(f"/cluster/firewall/groups/{n}", "POST", corps), f"regle de {n}") _fait(api(f"/cluster/firewall/groups/{n}", "POST", corps), f"regle de {n}")
for n, regles in sorted(p["regles_commenter"].items()):
for r in regles:
chemin = f"/cluster/firewall/groups/{n}/{r['pos']}"
if _fait(api(chemin, "PUT", {"comment": r["comment"]}), f"commentaire de {n}/{r['pos']}"):
if (_lire(api, chemin) or {}).get("comment") != r["comment"]:
echecs += 1
print(f" ! ECHEC commentaire {n}/{r['pos']} : relecture differente de l'ecriture")
# 3. Affectations aux VM, et activation de leur pare-feu — sans quoi les groupes # 3. Affectations aux VM, et activation de leur pare-feu — sans quoi les groupes
# seraient poses sur une machine qui ne filtre pas. # seraient poses sur une machine qui ne filtre pas.
for v, a in sorted(p["affect_majer"].items()): for v, a in sorted(p["affect_majer"].items()):
@ -286,7 +344,9 @@ def appliquer(api: Cluster, p: dict) -> int:
f"purge VM {v}") f"purge VM {v}")
for g in a["groupes"]: for g in a["groupes"]:
_fait(api(f"/nodes/{noeud}/qemu/{v}/firewall/rules", "POST", _fait(api(f"/nodes/{noeud}/qemu/{v}/firewall/rules", "POST",
{"type": "group", "action": g, "enable": 1}), f"VM {v} += {g}") {"type": "group", "action": g, "enable": 1,
"comment": _commentaire_affectation(a["hote"], p["_voulus"]["groupes"][g])}),
f"VM {v} += {g}")
# `policy_in` sur LA VM : c'est ce qui rend les groupes ci-dessus exclusifs. # `policy_in` sur LA VM : c'est ce qui rend les groupes ci-dessus exclusifs.
# Au datacenter, la meme valeur s'appliquerait a tout ce qui n'est pas un tenant. # Au datacenter, la meme valeur s'appliquerait a tout ce qui n'est pas un tenant.
# #
@ -308,6 +368,17 @@ def appliquer(api: Cluster, p: dict) -> int:
_fait(api(f"/nodes/{noeud}/qemu/{v}/firewall/options", "PUT", _fait(api(f"/nodes/{noeud}/qemu/{v}/firewall/options", "PUT",
{"enable": 1, "policy_in": POLITIQUE_VM}), f"pare-feu de la VM {v}") {"enable": 1, "policy_in": POLITIQUE_VM}), f"pare-feu de la VM {v}")
for v, regles in sorted(p["affect_commenter"].items()):
if v in p["affect_majer"]:
continue # les nouvelles affectations portent deja leur commentaire
noeud = p["affect_posees"][v]["noeud"]
for r in regles:
chemin = f"/nodes/{noeud}/qemu/{v}/firewall/rules/{r['pos']}"
if _fait(api(chemin, "PUT", {"comment": r["comment"]}), f"commentaire VM {v}/{r['pos']}"):
if (_lire(api, chemin) or {}).get("comment") != r["comment"]:
echecs += 1
print(f" ! ECHEC commentaire VM {v}/{r['pos']} : relecture differente de l'ecriture")
# 4. Retraits : groupes avant IPSets, un IPSet reference ne se supprime pas. # 4. Retraits : groupes avant IPSets, un IPSet reference ne se supprime pas.
for n in p["groupes_retirer"]: for n in p["groupes_retirer"]:
for r in reversed(api(f"/cluster/firewall/groups/{n}") or []): for r in reversed(api(f"/cluster/firewall/groups/{n}") or []):
@ -367,6 +438,7 @@ def main() -> int:
raise SystemExit(f"REFUS : VM hors du devis ou du cluster : {inconnues}") raise SystemExit(f"REFUS : VM hors du devis ou du cluster : {inconnues}")
ecartees = len(p["affect_majer"]) ecartees = len(p["affect_majer"])
p["affect_majer"] = {v: a for v, a in p["affect_majer"].items() if v in args.vm} p["affect_majer"] = {v: a for v, a in p["affect_majer"].items() if v in args.vm}
p["affect_commenter"] = {v: a for v, a in p["affect_commenter"].items() if v in args.vm}
ecartees -= len(p["affect_majer"]) ecartees -= len(p["affect_majer"])
print(f" (portee restreinte : {ecartees} affectation(s) de VM laissee(s) pour plus tard)\n") print(f" (portee restreinte : {ecartees} affectation(s) de VM laissee(s) pour plus tard)\n")
quelque_chose = afficher(p) quelque_chose = afficher(p)

1463
scripts/contexte.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -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 # 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. # 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 LONGUEUR_MAX_ALIAS = 31
# Meme abreviation que le pare-feu est-ouest (`devis_proxmox_fw` : serveur_ -> srv-), pour # Meme abreviation que le pare-feu est-ouest (`devis_proxmox_fw` : serveur_ -> srv-), pour
@ -384,6 +435,35 @@ def _services_du_plan_site() -> dict[str, list[str]]:
return {h: sorted(set(g)) for h, g in out.items()} return {h: sorted(set(g)) for h, g in out.items()}
_HOSTVARS_SITE: dict | None = None
def applicable_au_site(fl: dict, role: str, machines: list[str]) -> bool:
"""Le flux conditionnel s'applique-t-il a AU MOINS UNE machine du site qui porte le role ?
LE SITE N'EVALUAIT PAS `seulement_si` (2026-10-10). La boucle des locataires le faisait
(`applicable_chez`), celle du site emettait tout flux, condition ou non. Invisible tant
qu'aucune condition ne changeait de verdict au site ; vu le jour ou la sortie apt du
socle est devenue conditionnelle : `make flux` la retirait des machines du site, et le
devis de la frontiere la gardait. Deux couches, deux verdicts.
Meme evaluation que `resoudre_flux` pour le site : variables d'hote de l'inventaire du
site, puis defauts du role. Inventaire illisible -> la regle est emise (statu quo).
"""
global _HOSTVARS_SITE
if not fl.get("seulement_si"):
return True
if _HOSTVARS_SITE is None:
try:
import site_inventaire # noqa: PLC0415 — le site peut ne pas etre monte
_HOSTVARS_SITE = (site_inventaire.inventaire().get("_meta") or {}).get("hostvars") or {}
except Exception:
_HOSTVARS_SITE = {}
if not _HOSTVARS_SITE:
return True
return any(flux_applicable(fl, role, None, _HOSTVARS_SITE.get(m) or {}) for m in machines)
def _ports_du_plan_site() -> dict[str, str]: def _ports_du_plan_site() -> dict[str, str]:
"""Port declare au plan pour chaque role du site — ce que `port: derive` vaut ici. """Port declare au plan pour chaque role du site — ce que `port: derive` vaut ici.
@ -495,7 +575,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
admin_par_if: dict[str, dict[str, list[str]]] = {} admin_par_if: dict[str, dict[str, list[str]]] = {}
for nom, pfx, n in tenants: for nom, pfx, n in tenants:
etiquette = f"{pfx}{n['index']}" etiquette = f"{pfx}{n['index']}"
cidrs = admin_de(nom) cidrs = admin_du_tenant(nom)
admin_par_tenant[etiquette] = cidrs admin_par_tenant[etiquette] = cidrs
par_vpn = [c for c in cidrs if _par_le_vpn(c)] 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)] locaux = [c for c in cidrs if c not in par_vpn and _porte_par(c, reseaux_gestion)]
@ -559,13 +639,12 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
for nom, pfx, n in tenants: for nom, pfx, n in tenants:
etiquette = f"{pfx}{n['index']}" etiquette = f"{pfx}{n['index']}"
alias_tenant = f"SETOPS_TENANT_{etiquette}" alias_tenant = f"SETOPS_TENANT_{etiquette}"
inv = inventaire_de(nom) if face_publiee(nom) is None and inventaire_de(nom) is None:
if inv is None:
tenants_sans_inventaire.append(nom) tenants_sans_inventaire.append(nom)
continue continue
for role, fl in retenus: for role, fl in retenus:
# Un flux conditionnel (`seulement_si`) ne vaut que la ou son reglage le dit. # 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 continue
# AVANT L'ALIAS (2026-09-17). Le saut plus bas arrivait APRES la creation de # 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` # l'alias du role : un flux vers la frontiere declare par `serveur_prometheus`
@ -576,7 +655,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
if role == ROLE_SOCLE: if role == ROLE_SOCLE:
destination = alias_tenant destination = alias_tenant
else: else:
cibles = cibles_par_role(role, inv) cibles = cibles_du_tenant(role, nom)
if not cibles: if not cibles:
continue # role absent de ce tenant : aucune regle a poser continue # role absent de ce tenant : aucune regle a poser
a = nom_alias(role, etiquette) a = nom_alias(role, etiquette)
@ -887,9 +966,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
# au SSH du site rendrait l'insemination indiscernable d'un droit d'entree # au SSH du site rendrait l'insemination indiscernable d'un droit d'entree
# general — exactement ce que l'isolation refuse. # general — exactement ce que l'isolation refuse.
_runners_tenants = sorted({ip for _n2, _p2, _x2 in tenants _runners_tenants = sorted({ip for _n2, _p2, _x2 in tenants
for ip in (cibles_par_role("serveur_ops_tenant", for ip in cibles_du_tenant("serveur_ops_tenant", _n2)})
inventaire_de(_n2))
if inventaire_de(_n2) else [])})
if _runners_tenants: if _runners_tenants:
alias["SETOPS_RUNNERS_TENANTS"] = { alias["SETOPS_RUNNERS_TENANTS"] = {
"type": "host", "type": "host",
@ -909,6 +986,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 # n'y ecoutait — mais un chemin ouvert vers une machine qui n'en a pas besoin est
# exactement ce que le moindre privilege refuse. # exactement ce que le moindre privilege refuse.
def _publie(_nom_tenant: str) -> bool: 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: try:
import devis_reseau as _dr import devis_reseau as _dr
_f = _dr.DOSSIER_INSTANCES / _nom_tenant / "plan" / "domaines.yml" _f = _dr.DOSSIER_INSTANCES / _nom_tenant / "plan" / "domaines.yml"
@ -918,9 +998,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
except (OSError, ImportError): except (OSError, ImportError):
return False return False
_primaires_dns = sorted({ip for _n2, _p2, _x2 in tenants if _publie(_n2) _primaires_dns = sorted({ip for _n2, _p2, _x2 in tenants if _publie(_n2)
for ip in (cibles_par_role("serveur_powerdns", for ip in cibles_du_tenant("serveur_powerdns", _n2)})
inventaire_de(_n2))
if inventaire_de(_n2) else [])})
if _primaires_dns: if _primaires_dns:
alias["SETOPS_DNS_PRIMAIRES_TENANTS"] = { alias["SETOPS_DNS_PRIMAIRES_TENANTS"] = {
"type": "host", "type": "host",
@ -940,11 +1018,17 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"contenu": _ips, "contenu": _ips,
"description": f"Machines du site portant {_role}", "description": f"Machines du site portant {_role}",
} }
_machines_role = [str(m["nom"]) for m in _machines_site
if _role == "serveur_debian"
or _role in _services_site.get(m["nom"], [])]
for _fl in flux_par_role.get(_role, []): for _fl in flux_par_role.get(_role, []):
_entrant = _fl.get("sens") == "ingress" _entrant = _fl.get("sens") == "ingress"
_pairs_fl = _pairs(_fl) _pairs_fl = _pairs(_fl)
if "frontiere" in _pairs_fl: if "frontiere" in _pairs_fl:
continue # bloc dedie, plus bas (voir la boucle des tenants) continue # bloc dedie, plus bas (voir la boucle des tenants)
# Un flux conditionnel ne vaut que la ou son reglage le dit — au site aussi.
if not applicable_au_site(_fl, _role, _machines_role):
continue
if _entrant and "voisins_site" in _pairs_fl: if _entrant and "voisins_site" in _pairs_fl:
# Un tenant qui s'adresse au site : le paquet penetre le pare-feu par # Un tenant qui s'adresse au site : le paquet penetre le pare-feu par
# le lien de TRANSIT, pas par la patte du site. Une regle par voisin, # le lien de TRANSIT, pas par la patte du site. Une regle par voisin,
@ -1574,15 +1658,21 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
_idx_t = _n_t.get("index") _idx_t = _n_t.get("index")
if _idx_t is None: if _idx_t is None:
continue continue
try: _face_t = face_publiee(_nom_t)
_acces_t = (yaml.safe_load( if _face_t is not None:
(_dr.DOSSIER_INSTANCES / _nom_t / "plan" / "acces.yml").read_text(encoding="utf-8")) # Le tunnel n'existe que s'il a un pair actif : la face le dit (`tunnel` vide sinon).
or {}).get("acces_admin_vpn") or {} if not (_face_t.get("administration") or {}).get("tunnel"):
except (OSError, yaml.YAMLError): continue
continue else:
if not [n for n, c in _acces_t.items() try:
if isinstance(c, dict) and str(c.get("etat", "present")) == "present"]: _acces_t = (yaml.safe_load(
continue # aucun pair actif : aucune route, aucune regle (_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}" _etiq_t = f"{_pfx_t}{_idx_t}"
_alias_tunnel = f"SETOPS_ADMIN_{_etiq_t}_TUNNEL" _alias_tunnel = f"SETOPS_ADMIN_{_etiq_t}_TUNNEL"
alias[_alias_tunnel] = { alias[_alias_tunnel] = {

View file

@ -115,10 +115,13 @@ def _ponts_derives(base: Path | None) -> set[str]:
def constater(api: Cluster, place: dict, base: Path | None = None, def constater(api: Cluster, place: dict, base: Path | None = None,
routage: str = "sdn") -> list[dict]: routage: str = "sdn", ponts: set[str] | None = None) -> list[dict]:
"""Confronte chaque valeur au cluster. Renvoie une ligne par objet.""" """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] = [] lignes: list[dict] = []
place = {**place, "_instance": base} place = {**place, "_instance": base, "_ponts": ponts}
noeuds = [n.get("node") for n in _liste(api, "/nodes")] noeuds = [n.get("node") for n in _liste(api, "/nodes")]
voulu = place.get("proxmox_clone_noeud") 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 # 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. # 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": if routage == "sdn":
vnets = {v.get("vnet") for v in _liste(api, "/cluster/sdn/vnets")} vnets = {v.get("vnet") for v in _liste(api, "/cluster/sdn/vnets")}
manquants = [p for p in ponts_derives if p not in 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 return lignes
def afficher(lignes: list[dict], source: Path | None, depot: Path) -> bool: def nom_du_tenant(source: Path | None, locataire: str | None = None) -> str:
# `.resolve()` : sans lui, un tenant monte par symlink s'annonce « instance » — le nom """Le nom que le devis annonce. Un devis doit nommer ce qu'il a mesure.
# du lien, pas celui du tenant. Un devis doit nommer ce qu'il a mesure.
tenant = source.parents[3].resolve().name if source else "?" `.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"Devis du placement — tenant « {tenant} » sur l'hebergeur « {depot.name} »\n")
print(f" {'OBJET':<12} {'DEMANDE':<34} {'VERDICT'}") print(f" {'OBJET':<12} {'DEMANDE':<34} {'VERDICT'}")
ok = True ok = True
@ -216,11 +250,27 @@ def afficher(lignes: list[dict], source: Path | None, depot: Path) -> bool:
def main() -> int: def main() -> int:
ap = argparse.ArgumentParser(description=__doc__) ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--verifier", action="store_true", help="code de sortie seulement") 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() 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: 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 return 0
try: try:
api, depot = Cluster.depuis_hebergeur() api, depot = Cluster.depuis_hebergeur()
@ -230,7 +280,7 @@ def main() -> int:
base = source.parents[3] if source else None base = source.parents[3] if source else None
routage = underlay_mod.routage_tenants(underlay_mod.charger()) routage = underlay_mod.routage_tenants(underlay_mod.charger())
lignes = constater(api, place, base, routage) lignes = constater(api, place, base, routage, ponts)
if a.verifier: if a.verifier:
mauvais = [l for l in lignes if not l["existe"]] mauvais = [l for l in lignes if not l["existe"]]
if mauvais: if mauvais:
@ -241,7 +291,7 @@ def main() -> int:
print(f"Placement conforme : noeud, stockage, gabarit et reseaux des VM " print(f"Placement conforme : noeud, stockage, gabarit et reseaux des VM "
f"existent sur « {depot.name} ».") f"existent sur « {depot.name} ».")
return 0 return 0
return 0 if afficher(lignes, source, depot) else 1 return 0 if afficher(lignes, tenant, depot) else 1
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -144,17 +144,57 @@ def _cibles(fl: dict) -> list[dict]:
return [{"proto": fl.get("protocole", "tcp"), "dport": port} for port in _ports(fl)] 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: def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"""Le devis, en structure. Un bloc par tenant federe.""" """Le devis, en structure. Un bloc par tenant federe."""
flux_par_role = charger_flux() flux_par_role = charger_flux()
blocs: list[dict] = [] blocs: list[dict] = []
for nom, pfx, n in tenants: 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) inv = inventaire_de(nom)
if inv is None: if face is None and inv is None:
continue continue
etiquette = f"{pfx}{n['index']}" 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, # Les PLANIFIES comptent : le pare-feu se prepare avant que la VM existe,
# sinon le devis changerait a chaque materialisation d'hote. # sinon le devis changerait a chaque materialisation d'hote.
tous = set(_ip_par_hote(data)) 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`", "role": "Internet (tout sauf RFC 1918) — flux `externe`",
"membres": list(INTERNET), "membres": list(INTERNET),
} }
if admin_avec_tunnel(nom): if admin_du_tenant(nom):
ipsets[f"t{n['index']}-admin"] = { ipsets[f"t{n['index']}-admin"] = {
"role": "reseaux d'administration (intrant nftables_admin_ssh)", "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)): for groupe in sorted(_enfants(data)):
if not est_groupe_operationnel(groupe): 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, []): for fl in flux_par_role.get(groupe, []):
if fl.get("sens") != "ingress": if fl.get("sens") != "ingress":
continue continue
if not flux_applicable(fl, groupe, inv.parent.parent.parent): if not applicable(fl, groupe):
continue continue
pairs = _pairs(fl) pairs = _pairs(fl)
# `externe` est saute PAIR PAR PAIR, jamais le flux entier : le SSH du # `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 ( if "externe" in pairs and not (
str(fl.get("protocole", "tcp")).lower() != "icmp" and "22" in _ports(fl)): str(fl.get("protocole", "tcp")).lower() != "icmp" and "22" in _ports(fl)):
sources = [(f"t{n['index']}-internet", "internet")] 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)")) sources.append((f"t{n['index']}-admin", "administration (poste)"))
for cle_src, via in sources: for cle_src, via in sources:
utilises.add(cle_src) 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 # rendrait une liste vide et la regle serait sautee. Son IPSet
# existe deja (garde anti-lockout SSH) — on s'y refere directement. # existe deja (garde anti-lockout SSH) — on s'y refere directement.
if pair == "admin": if pair == "admin":
if not admin_avec_tunnel(nom): if not admin_du_tenant(nom):
continue continue
utilises.add(cle) utilises.add(cle)
for cible in _cibles(fl): 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 # `_ports` seulement pour TCP/UDP : appele sur un flux ICMP, il rangeait le
# type parmi les « ports derives sautes » — un flux pourtant rendu. # type parmi les « ports derives sautes » — un flux pourtant rendu.
if (str(fl.get("protocole", "tcp")).lower() != "icmp" 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") utilises.add(f"t{n['index']}-admin")
regles.append({ regles.append({
"action": "ACCEPT", "sens": "IN", "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}, "ipsets": {k: v for k, v in ipsets.items() if k in utilises},
"groupes": groupes, "groupes": groupes,
"affectations": affect, "sans_source": sorted(set(sans_source)), "affectations": affect, "sans_source": sorted(set(sans_source)),
"admin": admin_avec_tunnel(nom), "admin": admin_du_tenant(nom),
}) })
return {"blocs": blocs} return {"blocs": blocs}

View file

@ -59,6 +59,19 @@ def _plan_de(nom_instance: str) -> dict:
return charger_serveurs(p).get("serveurs") or {} 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: def pool_actif() -> str:
"""Pool du tenant ACTIF — ce que le playbook de clonage passe a la creation. """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"])) 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: def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"""{pools: [{pool, tenant, index, membres: [{nom, vmid, etat}], sans_vmid: []}]}""" """{pools: [{pool, tenant, index, membres: [{nom, vmid, etat}], sans_vmid: []}]}"""
blocs = [] blocs = []
for nom_instance, _prefixe, nomenclature in tenants: for nom_instance, _prefixe, nomenclature in tenants:
index = int(nomenclature["index"]) index = int(nomenclature["index"])
membres, sans_vmid = [], [] 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()): for nom, srv in sorted(_plan_de(nom_instance).items()):
_, seq = fonction_seq(nom) _, seq = fonction_seq(nom)
derive = deriver_nomenclature(str(srv.get("fonction", "")), seq, nomenclature) or {} 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("--verifier", action="store_true")
ap.add_argument("--pool-actif", action="store_true", ap.add_argument("--pool-actif", action="store_true",
help="nom du pool du tenant actif (consomme par make creer-vm)") 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", ap.add_argument("--pool-site", action="store_true",
help="nom invariable du pool du genome (consomme par make site-creer)") help="nom invariable du pool du genome (consomme par make site-creer)")
args = ap.parse_args(argv) args = ap.parse_args(argv)
@ -172,6 +214,9 @@ def main(argv: list[str]) -> int:
if args.pool_actif: if args.pool_actif:
print(pool_actif()) print(pool_actif())
return 0 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). # LE SITE NE DERIVE D'AUCUN INDEX, DONC SON POOL NE SE CALCULE PAS (2026-09-13).
# #

View file

@ -619,6 +619,20 @@ def decouvrir() -> list[tuple[str, str, dict]]:
return tenants 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]]: def decouvrir_du_site() -> list[tuple[str, str, dict]]:
"""Les tenants que CE SITE heberge — `decouvrir()` restreint par `underlay.tenants`. """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()) portes = underlay_mod.tenants_du_site(underlay_mod.charger())
if portes is None: if portes is None:
return tenants 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: for nom in portes:
if nom not in connus: n = _nomenclature_publiee(nom)
print(f"ATTENTION : `tenants` de l'underlay nomme « {nom} », " if n is None:
f"qu'aucun dossier frere ne fournit.", file=sys.stderr) if nom not in par_nom:
retenus = [t for t in tenants if t[0] in set(portes)] 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: if not retenus:
raise SystemExit("Aucun tenant heberge sur ce site : `underlay.tenants` n'en " raise SystemExit("Aucun tenant heberge sur ce site : `underlay.tenants` n'en "
"retient aucun de connu.") "retient aucun de connu.")

View file

@ -28,6 +28,13 @@ DEUX GESTES, DEUX COTES :
genome_colis.py etat <depot> genome_colis.py etat <depot>
Des deux cotes. Ce que cette copie porte, pour le confronter a la forge. 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 `--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 fabriquer une fusion que personne n'a decidee — et le message de git nomme alors exactement
ce qu'il faut reconcilier. 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() 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: def emballer(depot: Path, colis: Path, branche: str) -> int:
if not (depot / ".git").exists(): if not (depot / ".git").exists():
raise SystemExit(f"{depot} n'est pas un depot git — rien a emballer.") raise SystemExit(f"{depot} n'est pas un depot git — rien a emballer.")
colis.parent.mkdir(parents=True, exist_ok=True) 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), 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 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}") f"refs/heads/{branche}:refs/remotes/colis/{branche}")
_git(depot, "merge", "--ff-only", f"refs/remotes/colis/{branche}") _git(depot, "merge", "--ff-only", f"refs/remotes/colis/{branche}")
apres = _git(depot, "log", "-1", "--format=%H") 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 env = None
if cle: 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. # seule l'ECRITURE s'authentifie, et seulement le temps de cette commande.
r = subprocess.run( r = subprocess.run(
["git", "-C", str(depot), "-c", f"remote.origin.pushurl={pushurl}", ["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) capture_output=True, text=True, env=env)
sortie = ((r.stdout or "") + (r.stderr or "")).strip() sortie = ((r.stdout or "") + (r.stderr or "")).strip()
if r.returncode != 0: if r.returncode != 0:
raise SystemExit(f"la poussee a echoue :\n {sortie}") raise SystemExit(f"la poussee a echoue :\n {sortie}")
print(json.dumps({"avant": avant, "apres": apres, 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 return 0

View file

@ -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: def _zones_deleguees() -> list:
"""La zone de l'HEBERGEUR, et l'adresse a qui la demander. [] si aucune carte. """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 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. 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: try:
import underlay as underlay_mod import underlay as underlay_mod
zone = str((underlay_mod.lire_plan_site("10-intrants.yml") or {}) 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. # La patte de la frontiere que les tenants traversent — calculee une fois.
_temps_frontiere: list = [] _temps_frontiere: list = []
try: try:
_carte = underlay_mod.charger() _carte = None if FICHE_SITE is not None else underlay_mod.charger()
if _carte: 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 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 # 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 # 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 # 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. # 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") index_tenant = nomenclature.get("index")
# UNE MACHINE SANS FONCTION DECLAREE N'A PAS D'ADRESSE — ET LE DISAIT PAS (2026-08-24). # UNE MACHINE SANS FONCTION DECLAREE N'A PAS D'ADRESSE — ET LE DISAIT PAS (2026-08-24).

View file

@ -3882,12 +3882,37 @@ def preuve_depot_binaires_complet() -> tuple[bool, str]:
if not tenus: if not tenus:
return False, "Le depot de binaires ne declare aucun artefact (serveur_artefacts_directs vide)." return False, "Le depot de binaires ne declare aucun artefact (serveur_artefacts_directs vide)."
# LES LISTES D'ARTEFACTS (2026-10-08). Les applications de Nextcloud sont declarees en
# LISTE par leur role (`serveur_nextcloud_apps`) ; leur `dest` est donc un modele
# (`{{ serveur_nextcloud_app.nom }}`), et le site les tient en parcourant cette liste.
# Un modele attend CHACUN des noms de sa liste ; ces noms ne sont tenus que si la tache
# du site parcourt bien la liste — sans quoi le depot ne les tiendrait pas.
import yaml as _yaml
listes = {"serveur_nextcloud_app.nom": ("serveur_nextcloud", "serveur_nextcloud_apps")}
taches_site = (roles_dir / "serveur_artefacts" / "tasks" / "main.yml").read_text(encoding="utf-8")
noms_de: dict[str, list[str]] = {}
for modele, (role_liste, liste) in listes.items():
d = _yaml.safe_load((roles_dir / role_liste / "defaults" / "main.yml").read_text(encoding="utf-8")) or {}
noms_de[modele] = [str(a["nom"]) for a in d.get(liste) or []]
if liste in taches_site:
tenus |= set(noms_de[modele])
attendus: dict[str, str] = {} attendus: dict[str, str] = {}
# LE VERROU DU RUNNER (2026-10-09) : ses roues et ses collections, que `serveur_ops` va
# chercher. Tenues seulement si le site lit ce verrou.
verrou = _yaml.safe_load((RACINE / "verrou-runner.yml").read_text(encoding="utf-8")) or {}
noms_verrou = [str(e["nom"]) for k in ("python", "collections") for e in verrou.get(k) or []]
for nom in noms_verrou:
attendus[nom] = "serveur_ops (verrou-runner.yml)"
if "verrou-runner.yml" in taches_site:
tenus |= set(noms_verrou)
for role in sorted(p.name for p in roles_dir.iterdir() if p.is_dir()): for role in sorted(p.name for p in roles_dir.iterdir() if p.is_dir()):
for tf in sorted((roles_dir / role / "tasks").glob("*.yml")) if (roles_dir / role / "tasks").is_dir() else []: for tf in sorted((roles_dir / role / "tasks").glob("*.yml")) if (roles_dir / role / "tasks").is_dir() else []:
texte = tf.read_text(encoding="utf-8") texte = tf.read_text(encoding="utf-8")
for dest in _re.findall(r'^\s*dest:\s*"\{\{\s*\w+_cache_local\s*\}\}/([^"]+)"', texte, _re.M): for dest in _re.findall(r'^\s*dest:\s*"\{\{\s*\w+_cache_local\s*\}\}/([^"]+)"', texte, _re.M):
attendus[dest] = f"{role}/tasks/{tf.name}" m = _re.fullmatch(r'\{\{\s*([\w.]+)\s*\}\}', dest)
for nom in (noms_de.get(m.group(1), [dest]) if m else [dest]):
attendus[nom] = f"{role}/tasks/{tf.name}"
manquants = sorted(f"{nom} ({ou})" for nom, ou in attendus.items() if nom not in tenus) manquants = sorted(f"{nom} ({ou})" for nom, ou in attendus.items() if nom not in tenus)
if manquants: if manquants:
@ -4665,6 +4690,258 @@ def preuve_runbooks_suivent_le_makefile() -> tuple[bool, str]:
f"chacune portee par un assistant ou exemptee avec son motif.") 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] = [ PREUVES: list[dict] = [
{"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"], {"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"],
"cmds": [["ansible-lint", "-q"]]}, "cmds": [["ansible-lint", "-q"]]},
@ -4851,6 +5128,28 @@ PREUVES: list[dict] = [
"refs": [], "func": preuve_dns_public_tient}, "refs": [], "func": preuve_dns_public_tient},
{"id": "P83", "titre": "Assistants : le registre des runbooks ne prend pas de retard sur le Makefile", {"id": "P83", "titre": "Assistants : le registre des runbooks ne prend pas de retard sur le Makefile",
"refs": [], "func": preuve_runbooks_suivent_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},
] ]

View file

@ -16,9 +16,11 @@ liste que `genome-pousser` — lue, pas recopiee) :
demande de fusion acceptee, par exemple) : publier sans les integrer les ecraserait demande de fusion acceptee, par exemple) : publier sans les integrer les ecraserait
sur la forge du site ; sur la forge du site ;
2. signaler ce qui n'est pas committe — cela ne part pas ; 2. signaler ce qui n'est pas committe — cela ne part pas ;
3. pousser sur eregion ; 3. pousser sur eregion, la branche ET les etiquettes (2026-10-07 : les releases n'allaient
puis `make genome-pousser`, puis `make genome-etat` : la forge du site doit porter la meme nulle part d'elles-memes ; une etiquette deplacee est refusee, une release ne bouge pas) ;
tete que le poste, sinon on le dit. 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 tous les depots du genome
make publier DEPOT=ops-chezlepro make publier DEPOT=ops-chezlepro
@ -81,7 +83,7 @@ def main() -> int:
if sale: if sale:
print(f" ~ {d['depot']:<17} {len(sale)} changement(s) NON committe(s) — ils ne partent pas") 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 "?" 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: if r.returncode != 0:
refus.append(d["depot"]) refus.append(d["depot"])
print(f" !! {d['depot']:<17} push refuse : {r.stderr.strip()[:120]}") print(f" !! {d['depot']:<17} push refuse : {r.stderr.strip()[:120]}")

View file

@ -59,6 +59,28 @@ def instance_active() -> tuple[Path, str]:
return reel, nom_court(reel) 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]]: def plan_derive(base: Path) -> list[tuple[str, int]]:
"""(nom d'hote, vmid) tels que le PLAN les derive — la seule source légitime.""" """(nom d'hote, vmid) tels que le PLAN les derive — la seule source légitime."""
env = dict(os.environ, SETOPS_INSTANCE=str(base)) 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("--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("--hote", help="ne raser QUE cet hote (les quatre verrous s'appliquent)")
ap.add_argument("--confirmer", action="store_true") 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) a = ap.parse_args(argv)
base, nom_actif = instance_active() if a.locataire:
couples = plan_derive(base) # 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: if a.hote:
# Les quatre verrous restent en vigueur : on ne fait que RESTREINDRE la liste # 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 # 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: else:
presentes.append((nom, vmid, v.get("node"))) 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: for nom, vmid, noeud in presentes:
print(f" À DÉTRUIRE {vmid} {nom:16} sur {noeud}") print(f" À DÉTRUIRE {vmid} {nom:16} sur {noeud}")
for nom, vmid in absentes: for nom, vmid in absentes:
@ -155,12 +186,13 @@ def main(argv: list[str] | None = None) -> int:
if not a.confirmer: if not a.confirmer:
print(f"\n{len(presentes)} VM seraient DETRUITES, disques compris.\n" print(f"\n{len(presentes)} VM seraient DETRUITES, disques compris.\n"
f"Relancer avec : make raser CONFIRMER=true INSTANCE={nom_actif}" 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 "")) + (f" HOTE={a.hote}" if a.hote else ""))
return 0 return 0
if (a.instance or "").lower() != nom_actif: if (a.instance or "").lower() != nom_actif:
print(f"\nREFUS : il faut NOMMER l'ecosysteme a raser.\n" 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.") f"Taper le nom est ce qui distingue le POC de la production.")
return 2 return 2

View file

@ -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) : 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 raser runner du site detruire les VM
creer runner du site recreer les VM depuis le gabarit creer runner du site recreer les VM depuis le gabarit
inseminer runner du site amorcer le runner du locataire (socle + moteur, sans secret) 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 monter runner locataire `make monter-flotte` : socle, tout, recette ; chaque role
proprietaire d'un etat y remet celui d'avant proprietaire d'un etat y remet celui d'avant
parefeu poste reactiver le pare-feu Proxmox, VM par VM, runner en dernier 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 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. 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: 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 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 `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 `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. 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", rc = lancer(["ansible-playbook", "-i", str(inv), "playbooks/backup/restauration.yml",
"-e", "restauration_action=sauvegarder"], env=env, "-e", "restauration_action=sauvegarder"], env=env,
filtre=("fatal:", "PLAY RECAP", "failed=")) 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": 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)}", f"INSTANCE={shlex.quote(ecosysteme)}",
filtre=("detruite", "VM detruites", "Refus", "REFUS", "ERREUR")) filtre=("detruite", "VM detruites", "Refus", "REFUS", "ERREUR"))
elif nom == "creer": 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", f"CONFIRMER=true PARALLELE=4",
filtre=("=== Creation", "a ECHOUE", "Toutes les VM", "Refus")) filtre=("=== Creation", "a ECHOUE", "Toutes les VM", "Refus"))
elif nom == "inseminer": elif nom == "inseminer":
@ -259,7 +266,14 @@ def main() -> int:
env=env, filtre=(" ",)) env=env, filtre=(" ",))
else: # bilan else: # bilan
rc = lancer(["ansible-playbook", "-i", str(inv), "playbooks/backup/restauration.yml"], env=env, 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: if rc != 0:
return arret(nom, rc) return arret(nom, rc)
dire(f" etape « {nom} » : OK") dire(f" etape « {nom} » : OK")

View file

@ -87,6 +87,28 @@ class ErreurFlux(Exception):
pass pass
def libelle_flux(role: str, raison: str) -> str:
"""Le role et sa justification, sur une ligne lisible par les trois pare-feux."""
texte = f"{role}: {raison}"
return " ".join("".join(c if c.isprintable() else " " for c in texte).split())
def borner_libelle(texte: str, octets: int) -> str:
"""Borne un commentaire UTF-8 sans couper un caractere ; la raison reste au registre."""
contenu = texte.encode("utf-8")
if len(contenu) <= octets:
return texte
if octets < 4:
raise ErreurFlux("Identifiant trop long : aucune place pour la description du flux.")
return contenu[:octets - 3].decode("utf-8", errors="ignore").rstrip() + "..."
def commentaire_nft(role: str, raison: str) -> str:
"""Un commentaire NATIF (conserve par nft), avec guillemets et UTF-8 proteges."""
texte = borner_libelle(libelle_flux(role, raison), 127)
return "comment " + json.dumps(texte, ensure_ascii=False)
def charger_flux() -> dict[str, list[dict]]: def charger_flux() -> dict[str, list[dict]]:
"""{role: [flux, ...]} pour chaque role portant un meta/flux.yml.""" """{role: [flux, ...]} pour chaque role portant un meta/flux.yml."""
resultat: dict[str, list[dict]] = {} resultat: dict[str, list[dict]] = {}
@ -182,6 +204,8 @@ def valider(flux_par_role: dict[str, list[dict]]) -> tuple[int, int]:
for cle in ("sens", "port", "protocole", "pair", "chiffrement", "raison"): for cle in ("sens", "port", "protocole", "pair", "chiffrement", "raison"):
if cle not in fl: if cle not in fl:
erreurs.append(f"{ref} : cle manquante {cle}") erreurs.append(f"{ref} : cle manquante {cle}")
if not isinstance(fl.get("raison"), str) or not fl["raison"].strip():
erreurs.append(f"{ref} : raison attendue (texte non vide pour le libelle du pare-feu)")
if fl.get("sens") not in SENS: if fl.get("sens") not in SENS:
erreurs.append(f"{ref} : sens invalide {fl.get('sens')!r}") erreurs.append(f"{ref} : sens invalide {fl.get('sens')!r}")
if fl.get("protocole") not in PROTO: if fl.get("protocole") not in PROTO:
@ -764,7 +788,8 @@ def generer_nftables(flux_par_role: dict[str, list[dict]], site: bool = False) -
ip_par_hote = _ip_par_hote(data) ip_par_hote = _ip_par_hote(data)
ports_plan = _ports_du_plan_site() if site else _ports_du_plan() ports_plan = _ports_du_plan_site() if site else _ports_du_plan()
regles_admin = [ regles_admin = [
f" ip saddr {{ {cidr} }} tcp dport 22 accept # administration (garde anti-lockout, intrant nftables_admin_ssh)" f" ip saddr {{ {cidr} }} tcp dport 22 accept "
+ commentaire_nft("administration", "Garde anti-lockout, intrant nftables_admin_ssh")
for cidr in admin_ssh for cidr in admin_ssh
] ]
if site: if site:
@ -785,12 +810,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()} hote_par_ip = {ip: h for h, ip in ip_par_hote.items()}
sorties: dict[str, dict[tuple, dict]] = {} sorties: dict[str, dict[tuple, dict]] = {}
entrees: dict[str, list[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): for hote in sorted(actifs_noms):
groupes = [g for g, membres in _enfants(data).items() groupes = [g for g, membres in _enfants(data).items()
if hote in (membres.get("hosts") or {}) and est_groupe_operationnel(g)] if hote in (membres.get("hosts") or {}) and est_groupe_operationnel(g)]
regles: list[str] = list(regles_admin) regles: list[str] = list(regles_admin)
for g in sorted(groupes): for g in sorted(groupes):
for fl in flux_par_role.get(g, []): 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": if fl.get("sens") != "ingress":
continue continue
if not flux_applicable(fl, g, dossier_ecosysteme, if not flux_applicable(fl, g, dossier_ecosysteme,
@ -866,10 +903,17 @@ def generer_nftables(flux_par_role: dict[str, list[dict]], site: bool = False) -
if local and not srcs: if local and not srcs:
continue # lo deja accepte continue # lo deja accepte
regles.append(f" {saddr}{_selecteur_nft(fl['protocole'], port)} accept" regles.append(f" {saddr}{_selecteur_nft(fl['protocole'], port)} accept"
f" # {g}: {fl.get('raison','')}") f" {commentaire_nft(g, fl['raison'])}")
entrees.setdefault(hote, []).append( entree = {"proto": proto, "port": str(port), "role": g,
{"proto": proto, "port": str(port), "role": g, "sources": list(srcs) if saddr else ["0.0.0.0/0"]}
"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 # Testable depuis la source : TCP, et l'ICMP « echo » (ping). L'UDP ne se
# « connecte » pas ; les autres types ICMP ne se provoquent pas. # « connecte » pas ; les autres types ICMP ne se provoquent pas.
if proto == "tcp" or (proto == "icmp" and str(port) == "echo-request"): if proto == "tcp" or (proto == "icmp" and str(port) == "echo-request"):
@ -907,6 +951,8 @@ def generer_nftables(flux_par_role: dict[str, list[dict]], site: bool = False) -
key=lambda x: (x["vers"], x["proto"], x["port"])), key=lambda x: (x["vers"], x["proto"], x["port"])),
"entrees": entrees.get(hote, []), "entrees": entrees.get(hote, []),
"admin_ssh": list(admin_ssh), "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") }, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
ecrits.append(chemin) ecrits.append(chemin)
for orphelin in sorted(list(sortie_dir.glob("*.nft")) + list(sortie_dir.glob("*.connectivite.json"))): for orphelin in sorted(list(sortie_dir.glob("*.nft")) + list(sortie_dir.glob("*.connectivite.json"))):
@ -942,11 +988,11 @@ def _rendre_nft(hote: str, regles: list[str]) -> str:
f"table inet setops_flux {{\n" f"table inet setops_flux {{\n"
f" chain input {{\n" f" chain input {{\n"
f" type filter hook input priority 0; policy drop;\n\n" f" type filter hook input priority 0; policy drop;\n\n"
f" iif \"lo\" accept\n" f" iif \"lo\" accept comment \"socle: communications locales\"\n"
f" ct state established,related accept\n" f" ct state established,related accept comment \"socle: connexions etablies et associees\"\n"
f" ct state invalid drop\n" f" ct state invalid drop comment \"socle: paquets invalides\"\n"
f" ip protocol icmp accept\n" f" ip protocol icmp accept comment \"socle: diagnostic et erreurs reseau IPv4\"\n"
f" ip6 nexthdr icmpv6 accept\n\n" f" ip6 nexthdr icmpv6 accept comment \"socle: diagnostic et erreurs reseau IPv6\"\n\n"
f"{corps}\n\n" f"{corps}\n\n"
f" # L'INTERNE PARLE, LA BORDURE SE TAIT (2026-08-28).\n" f" # L'INTERNE PARLE, LA BORDURE SE TAIT (2026-08-28).\n"
f" #\n" f" #\n"
@ -971,7 +1017,7 @@ def _rendre_nft(hote: str, regles: list[str]) -> str:
f" # moitie du choix : 982 000 entrees par jour, dont 82 %% un balayage\n" f" # moitie du choix : 982 000 entrees par jour, dont 82 %% un balayage\n"
f" # Internet contre le port VNC. Repondre a chacune serait un vecteur\n" f" # Internet contre le port VNC. Repondre a chacune serait un vecteur\n"
f" # d'amplification, source usurpee comprise.\n" f" # d'amplification, source usurpee comprise.\n"
f" reject with icmpx type admin-prohibited\n" f" reject with icmpx type admin-prohibited comment \"socle: refus des entrees non autorisees\"\n"
f" }}\n\n" f" }}\n\n"
f" # LA CHAINE `forward` RESTE MUETTE, et ce n'est pas un oubli : elle porte le\n" f" # LA CHAINE `forward` RESTE MUETTE, et ce n'est pas un oubli : elle porte le\n"
f" # trafic qui TRAVERSE cet hote, pas celui qui lui est destine. Y repondre\n" f" # trafic qui TRAVERSE cet hote, pas celui qui lui est destine. Y repondre\n"
@ -980,9 +1026,9 @@ def _rendre_nft(hote: str, regles: list[str]) -> str:
f" # defaut qu'on corrige dans `input`.\n" f" # defaut qu'on corrige dans `input`.\n"
f" chain forward {{\n" f" chain forward {{\n"
f" type filter hook forward priority 0; policy drop;\n" f" type filter hook forward priority 0; policy drop;\n"
f" ct state established,related accept\n" f" ct state established,related accept comment \"transit: connexions etablies et associees\"\n"
f" iifname \"docker0\" accept\n" f" iifname \"docker0\" accept comment \"transit: trafic depuis le pont docker0\"\n"
f" oifname \"docker0\" accept\n" f" oifname \"docker0\" accept comment \"transit: trafic vers le pont docker0\"\n"
f" }}\n" f" }}\n"
f" chain output {{ type filter hook output priority 0; policy accept; }}\n" f" chain output {{ type filter hook output priority 0; policy accept; }}\n"
f"}}\n" f"}}\n"

View file

@ -145,6 +145,23 @@ def _charger(nom: str) -> dict:
return yaml.safe_load(f.read_text(encoding="utf-8")) or {} 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: def inventaire() -> dict:
u = U.charger() u = U.charger()
if u is None: if u is None:
@ -210,13 +227,21 @@ def inventaire() -> dict:
except Exception: except Exception:
return out return out
for nom_depot, prefixe, _n in decouverts: for nom_depot, prefixe, _n in decouverts:
gv = (racine_instances / nom_depot / "inventories" / "principal" # LA FACE RESEAU D'ABORD (2026-10-05, docs/conception-contextes.md §6, etape 3). Le
/ "group_vars" / "serveur_backup.yml") # locataire PUBLIE la cle qu'il depose (`face-reseau.yml`) ; le site ne fouille plus
try: # ses `group_vars`, dont le chemin etait ecrit en dur (`principal`). Sans face
cle = str((yaml.safe_load(gv.read_text(encoding="utf-8")) or {}) # publiee (transition), l'ancienne lecture, et on le dit.
.get("serveur_backup_pubkey") or "").strip() face = _face_publiee(racine_instances / nom_depot)
except OSError: if face is not None:
cle = "" 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 : # 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 # 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 # compte, et surtout on n'en ouvre pas un SANS cle — il accepterait alors
@ -253,6 +278,22 @@ def inventaire() -> dict:
except Exception: except Exception:
return out return out
for nom_depot, _prefixe, _n in decouverts: 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" plan = racine_instances / nom_depot / "plan"
try: try:
doms = (yaml.safe_load((plan / "domaines.yml").read_text(encoding="utf-8")) doms = (yaml.safe_load((plan / "domaines.yml").read_text(encoding="utf-8"))

View 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())

View file

@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""Les applications Nextcloud viennent du depot du SITE, epinglees — plus de l'app store.
POURQUOI (2026-10-08). `occ app:install` tirait `user_oidc` et `richdocuments`
d'apps.nextcloud.com a chaque construction ; l'app store servant son catalogue a 77 ko/s,
la reconstruction de Technolibre s'est arretee. Ce test garde la declaration coherente : une
version qui differerait entre `nom` et `url` ferait tenir au site un fichier que personne ne
demande, et la divergence se lirait « le depot sert un fichier », pas « le mauvais ».
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
import yaml
RACINE = Path(__file__).resolve().parents[2]
ECHECS: list[str] = []
def verifier(cond: bool, msg: str) -> None:
print(("OK " if cond else "ECHEC ") + msg)
if not cond:
ECHECS.append(msg)
def main() -> int:
apps = yaml.safe_load((RACINE / "roles/serveur_nextcloud/defaults/main.yml").read_text())["serveur_nextcloud_apps"]
verifier(len({a["id"] for a in apps}) == len(apps) and len(apps) > 0, f"{len(apps)} application(s), identifiants uniques")
for a in apps:
v, i = a["version"], a["id"]
verifier(f"-{i}-{v}." in a["nom"] and a["nom"].startswith("nextcloud-app-"), f"{i} : le nom porte l'id et la version ({a['nom']})")
verifier(f"/{i}/" in a["url"] and f"v{v}" in a["url"], f"{i} : l'url porte l'id et la version")
verifier(re.fullmatch(r"[0-9a-f]{64}", a["sha256"]) is not None, f"{i} : sha256 bien formee")
taches = (RACINE / "roles/serveur_artefacts/tasks/main.yml").read_text()
verifier("serveur_nextcloud_apps" in taches, "le site tient les applications declarees par serveur_nextcloud")
# Les commandes, pas les commentaires : `restaurer.yml` raconte encore pourquoi
# `occ app:install` refusait une base restauree trop tot.
roles = "".join(l for p in (RACINE / "roles/serveur_nextcloud/tasks").glob("*.yml")
for l in p.read_text().splitlines(keepends=True) if not l.lstrip().startswith("#"))
verifier("app:install" not in roles, "plus aucune tache ne tire une application de l'app store (`app:install`)")
for f, app in (("oidc.yml", "user_oidc"), ("collabora.yml", "richdocuments")):
t = (RACINE / "roles/serveur_nextcloud/tasks" / f).read_text()
verifier(f"serveur_nextcloud_app_id: {app}" in t, f"{f} installe {app} par app_tierce.yml")
if ECHECS:
print(f"\n{len(ECHECS)} echec(s).")
return 1
print("\nLes applications Nextcloud sont epinglees, tenues par le site, installees sans l'app store.")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Aucune sortie vers Internet n'est donnee a TOUTE machine, et le site lit les conditions.
POURQUOI (2026-10-10). Le socle donnait a chaque machine une sortie 80/443 vers tout
Internet, « pour apt ». Apt passait deja par le cache du site : la sortie ne servait plus
qu'a ce qu'aucun role ne declarait — telemetrie de Grafana, d'Alloy et de Loki, verification
de version de Collabora, cartes de rspamd. Elle est devenue conditionnelle
(`seulement_si: artefacts_amorcage` vide), et deux defauts sont apparus :
1. un role porte par toute machine (le socle, les `client_*`) peut rouvrir Internet a
toute la flotte sans que rien ne le signale ;
2. le devis de la frontiere n'evaluait `seulement_si` que chez les locataires : au site,
tout flux conditionnel etait emis. `make flux` retirait la sortie des machines du site,
la frontiere la gardait.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
RACINE = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(RACINE / "scripts"))
import devis_opnsense as dv # noqa: E402
from resoudre_flux import charger_flux # noqa: E402
UNIVERSELS = {"serveur_debian", "serveur_durci"}
def main() -> int:
echecs: list[str] = []
flux = charger_flux()
# 1. Les roles universels n'ouvrent pas Internet sans condition (hors ICMP du PMTUD).
for role, liste in flux.items():
if role not in UNIVERSELS and not role.startswith("client_"):
continue
for fl in liste:
pairs = fl.get("pair") if isinstance(fl.get("pair"), list) else [fl.get("pair")]
if (fl.get("sens") == "egress" and "externe" in pairs
and str(fl.get("protocole")).lower() in ("tcp", "udp")
and not fl.get("seulement_si")):
echecs.append(f"{role} : sortie {fl.get('protocole')}/{fl.get('port')} vers "
f"`externe` sans condition — donnee a TOUTE machine")
# 2. L'evaluation au site, sur des variables d'hote connues.
socle = [fl for fl in flux.get("serveur_debian", [])
if fl.get("sens") == "egress" and fl.get("seulement_si")
and (fl.get("seulement_si") or {}).get("variable") == "artefacts_amorcage"]
if not socle:
echecs.append("serveur_debian : la sortie apt n'est plus conditionnee a `artefacts_amorcage`")
else:
fl = socle[0]
dv._HOSTVARS_SITE = {"avec": {"artefacts_amorcage": "10.0.0.1:3142"}, "sans": {}}
attendus = [(["avec"], False), (["sans"], True), (["avec", "sans"], True)]
for machines, attendu in attendus:
obtenu = dv.applicable_au_site(fl, "serveur_debian", machines)
if obtenu is not attendu:
echecs.append(f"applicable_au_site({machines}) = {obtenu}, attendu {attendu}")
dv._HOSTVARS_SITE = {}
if dv.applicable_au_site(fl, "serveur_debian", ["avec"]) is not True:
echecs.append("inventaire du site illisible : la regle doit rester emise (statu quo)")
dv._HOSTVARS_SITE = None
# 3. La boucle du site appelle bien l'evaluation, avant tout rendu de regle.
source = (RACINE / "scripts" / "devis_opnsense.py").read_text(encoding="utf-8")
boucle = re.search(r"for _fl in flux_par_role\.get\(_role, \[\]\):(.{0,600})", source, re.S)
if not boucle or "applicable_au_site(" not in boucle.group(1):
echecs.append("devis_opnsense : la boucle des flux du site n'evalue plus `seulement_si`")
for e in echecs:
print(f"ECHEC {e}")
if echecs:
return 1
print("OK — aucun role universel n'ouvre Internet ; le site lit les conditions.")
return 0
if __name__ == "__main__":
sys.exit(main())

View 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())

View file

@ -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" "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__": if __name__ == "__main__":
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
for t in tests: for t in tests:

View 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())

View file

@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""Les raisons du registre survivent dans les pare-feux et se corrigent sans recreer les regles."""
from __future__ import annotations
import copy
import io
import json
import sys
import tempfile
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from unittest.mock import patch
import jinja2
import yaml
RACINE = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(RACINE / "scripts"))
import appliquer_opnsense as opn
import appliquer_proxmox_fw as pve
import resoudre_flux as flux
class ClusterFictif:
"""Une regle deja posee et une affectation ; toute ecriture de politique est refusee."""
hote = "cluster-fictif"
def __init__(self, ignorer=False):
self.ignorer = ignorer
self.ecritures = []
self.regle = {"pos": 3, "type": "in", "action": "ACCEPT", "source": "+t90-app",
"proto": "tcp", "dport": "5432", "enable": 0, "comment": "ancien"}
self.affectation = {"pos": 7, "type": "group", "action": "t90-pg", "enable": 1}
@staticmethod
def rate(rep):
return None
def __call__(self, chemin, methode="GET", corps=None):
objets = {"/cluster/firewall/groups/t90-pg/3": self.regle,
"/nodes/noeud/qemu/9001/firewall/rules/7": self.affectation}
if methode != "GET":
assert methode == "PUT" and chemin in objets and set(corps) == {"comment"}
self.ecritures.append((chemin, copy.deepcopy(corps)))
if not self.ignorer:
objets[chemin].update(corps)
return None
if chemin in objets:
return copy.deepcopy(objets[chemin])
lectures = {
"/cluster/firewall/ipset": [{"name": "t90-app"}],
"/cluster/firewall/ipset/t90-app": [{"cidr": "192.0.2.10"}],
"/cluster/firewall/groups": [{"group": "t90-pg"}],
"/cluster/firewall/groups/t90-pg": [self.regle],
"/cluster/resources?type=vm": [{"vmid": 9001, "node": "noeud"}],
"/nodes/noeud/qemu/9001/firewall/rules": [self.affectation],
"/nodes/noeud/qemu/9001/firewall/options": {"enable": 1, "policy_in": "REJECT"},
"/cluster/firewall/options": {"enable": 1, "policy_in": "ACCEPT"},
}
return copy.deepcopy(lectures[chemin])
class FrontiereFictive:
"""Les trois sortes de regles : des descriptions anciennes, des identites stables."""
def __init__(self, devis, ignorer=False):
self.ignorer = ignorer
self.ecritures = []
self.objets = {}
for objet, cle, champ, source in (
("filter", opn.cle_regle, "description", "regles"),
("source_nat", opn.cle_nat, "description", "nat"),
("d_nat", opn.cle_redirection, "descr", "redirections")):
self.objets[objet] = {"uuid": objet, champ: cle(devis[source][0]) + " — ancien",
"enabled": "0", "sequence": "42"}
def __call__(self, chemin, corps=None):
for objet, courant in self.objets.items():
prefixe = f"/api/firewall/{objet}/"
if not chemin.startswith(prefixe):
continue
commande = chemin[len(prefixe):].split("/")[0]
if commande == "search_rule":
# La regle humaine n'entre jamais dans le perimetre gere.
return {"rows": [copy.deepcopy(courant), {"uuid": "humaine", "description": "Personnel"}]}
if commande == "get_rule":
return {"rule": copy.deepcopy(courant)}
if commande == "set_rule":
champ = "descr" if objet == "d_nat" else "description"
assert set(corps) == {"rule"} and set(corps["rule"]) == {champ}
self.ecritures.append((chemin, copy.deepcopy(corps)))
if not self.ignorer:
courant.update(corps["rule"])
return {"result": "saved"}
if commande == "apply":
return {"status": "ok"}
raise AssertionError(f"Ecriture de politique inattendue : {chemin}")
if chemin == "/api/diagnostics/interface/getRoutes":
return [{"destination": "default"}]
if chemin in ("/api/firewall/alias/searchItem/", "/api/routes/routes/searchroute/"):
return {"rows": []}
if chemin == "/api/firewall/alias/reconfigure/":
return {"status": "ok"}
raise AssertionError(chemin)
class LibellesParefeu(unittest.TestCase):
def setUp(self):
self.sortie = redirect_stdout(io.StringIO())
self.sortie.__enter__()
self.addCleanup(self.sortie.__exit__, None, None, None)
def test_nft_genere_des_commentaires_natifs_et_protege_le_texte(self):
raison = 'Accès "base" via C:\\réseau\n' + 'é' * 150
declar = {"serveur_postgresql": [{"sens": "ingress", "port": 5432,
"protocole": "tcp", "pair": "serveur_keycloak",
"chiffrement": "tls-requis", "raison": raison}]}
data = {"all": {"children": {
"hotes_actifs": {"hosts": {"base": {"ansible_host": "192.0.2.20"},
"app": {"ansible_host": "192.0.2.10"}}},
"serveur_postgresql": {"hosts": {"base": {}}},
"serveur_keycloak": {"hosts": {"app": {}}}}}}
with tempfile.TemporaryDirectory() as tmp, \
patch.object(flux, "_inventaire_site", return_value=data), \
patch.object(flux, "_sources_admin_ssh_site", return_value=["192.0.2.0/24"]), \
patch.object(flux, "_ports_du_plan_site", return_value={}), \
patch("underlay.chemin", return_value=Path(tmp) / "underlay.yml"):
flux.generer_nftables(declar, site=True)
texte = (Path(tmp) / "flux-genere/base.nft").read_text()
regle = next(l for l in texte.splitlines() if "tcp dport 5432" in l)
commentaire = json.loads(regle.split(" comment ", 1)[1])
self.assertTrue(commentaire.startswith('serveur_postgresql: Accès "base" via C:\\réseau '))
self.assertLessEqual(len(commentaire.encode("utf-8")), 127)
self.assertTrue(commentaire.endswith("..."))
for ligne in texte.splitlines():
if ligne.strip().startswith(("ip ", "ip6 ", "iif", "oif", "ct ", "reject ")):
self.assertIn(" comment ", ligne)
def test_nft_repli_annote_toutes_ses_regles(self):
dossier = RACINE / "roles/nftables_baseline"
valeurs = yaml.safe_load((dossier / "defaults/main.yml").read_text())
valeurs.update(nftables_baseline_extra_tcp_ports=[8080], nftables_baseline_extra_udp_ports=[53])
env = jinja2.Environment()
env.filters["bool"] = bool
texte = env.from_string((dossier / "templates/nftables.conf.j2").read_text()).render(valeurs)
regles = [l for l in texte.splitlines() if l.strip().startswith(("iif ", "ct ", "ip ", "ip6 ", "tcp ", "udp "))]
self.assertGreaterEqual(len(regles), 8)
self.assertTrue(all(" comment " in l for l in regles))
def test_raison_vide_refusee(self):
fl = copy.deepcopy(flux.charger_flux())
fl["serveur_postgresql"][0]["raison"] = " \n "
with self.assertRaisesRegex(flux.ErreurFlux, "raison attendue"):
flux.valider(fl)
def devis_proxmox(self):
return {"blocs": [{"ipsets": {"t90-app": {"role": "application", "membres": ["192.0.2.10"]}},
"groupes": [{"nom": "t90-pg", "role": "serveur_postgresql", "regles": [
{"sens": "IN", "action": "ACCEPT", "source": "+t90-app", "proto": "tcp",
"dport": "5432", "via": "serveur_keycloak", "raison": "Persistance du SSO."}]}],
"affectations": [{"vmid": 9001, "hote": "base", "groupes": ["t90-pg"]}]}]}
def test_proxmox_corrige_en_place_puis_converge(self):
api = ClusterFictif()
devis = self.devis_proxmox()
with patch.object(pve, "_prefixes_retires", return_value=()):
p = pve.plan(api, devis)
self.assertFalse(p["groupes_majer"] or p["affect_majer"])
self.assertTrue(pve.afficher(p))
self.assertEqual(pve.appliquer(api, p), 0)
self.assertEqual(len(api.ecritures), 2)
self.assertEqual(api.regle["enable"], 0)
self.assertIn("Persistance du SSO.", api.regle["comment"])
self.assertFalse(pve.afficher(pve.plan(api, devis)))
def test_proxmox_detecte_une_ecriture_sans_effet(self):
api = ClusterFictif(ignorer=True)
with patch.object(pve, "_prefixes_retires", return_value=()):
self.assertEqual(pve.appliquer(api, pve.plan(api, self.devis_proxmox())), 1)
def devis_frontiere(self):
return {"alias": {}, "regles": [{"tenant": "exemple", "interface": "wan", "sens": "in",
"protocole": "tcp", "source": "any", "destination": "WEB", "ports": [443],
"role": "serveur_web_frontal", "raison": "Publication HTTPS des sites."}],
"nat": [{"tenant": "exemple", "interface": "wan", "source": "TENANT",
"destination": "any", "cible": "wanip"}],
"redirections": [{"interface": "wan", "protocole": "tcp", "port_public": 443,
"cible": "192.0.2.20", "port_local": 443, "role": "serveur_web_frontal"}]}
def test_opnsense_corrige_par_uuid_puis_converge(self):
devis = self.devis_frontiere()
api = FrontiereFictive(devis)
p = opn.plan(api, devis)
for categorie in ("regles", "nat", "rdr"):
self.assertFalse(p[categorie + "_creer"] or p[categorie + "_retirer"])
self.assertTrue(opn.afficher(p))
self.assertEqual(opn.appliquer(api, p), 0)
self.assertEqual(len(api.ecritures), 3)
self.assertTrue(all(x["enabled"] == "0" and x["sequence"] == "42" for x in api.objets.values()))
self.assertIn("Publication HTTPS des sites.", api.objets["filter"]["description"])
self.assertFalse(opn.afficher(opn.plan(api, devis)))
devis["regles"][0]["raison"] = "Nouvelle justification."
self.assertEqual(len(opn.plan(api, devis)["descriptions_majer"]), 1)
def test_opnsense_detecte_une_ecriture_sans_effet(self):
devis = self.devis_frontiere()
api = FrontiereFictive(devis, ignorer=True)
self.assertEqual(opn.appliquer(api, opn.plan(api, devis)), 1)
def test_opnsense_garde_la_cle_et_borne_les_octets(self):
r = self.devis_frontiere()["regles"][0]
r["raison"] = 'Accès "HTTPS"\n' + 'é' * 300
cle = opn.cle_regle(r)
texte = opn._corps_regle(r, cle)["description"]
self.assertEqual(texte.split(" — ")[0], cle)
self.assertIn('Accès "HTTPS"', texte)
self.assertNotIn("\n", texte)
self.assertLessEqual(len(texte.encode()), 255)
with self.assertRaises(flux.ErreurFlux):
opn._description("x" * 255, "role", "raison")
if __name__ == "__main__":
unittest.main()

View file

@ -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)") print(f">>> code de sortie : {code} (2 = refus attendu)")
assert code == 2, "LE VERROU N'A PAS TENU" assert code == 2, "LE VERROU N'A PAS TENU"
print(">>> le verrou tient : aucune VM n'aurait ete touchee") 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}")

View file

@ -36,6 +36,8 @@ import yaml
RACINE = Path(__file__).resolve().parents[2] RACINE = Path(__file__).resolve().parents[2]
GABARIT = RACINE / "roles/client_backup/templates/restaurer.sh.j2" 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] = [] ECHECS: list[str] = []
@ -120,9 +122,17 @@ def outil() -> None:
dump = snap / str(staging).lstrip("/") / "postgresql" / "toutes-bases.sql" dump = snap / str(staging).lstrip("/") / "postgresql" / "toutes-bases.sql"
dump.parent.mkdir(parents=True) dump.parent.mkdir(parents=True)
dump.write_text(DUMP) 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 = [ snaps = [
{"short_id": "avant01", "id": "avant01" + "0" * 56, "time": "2026-09-29T23:50:50.123456789-04:00"}, {"short_id": "rasage03", "id": "rasage03" + "0" * 56, "time": "2026-09-29T10:00:00-04:00",
{"short_id": "apres02", "id": "apres02" + "0" * 56, "time": "2099-01-01T00:00:00Z"}, "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)) (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; }} [[ "$1" == --retry-lock && -n "$2" ]] || {{ echo "restic appele sans --retry-lock : $*" >&2; exit 99; }}
shift 2 shift 2
case "$1" in 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) restore)
id="$2"; shift 2; cible=""; inc=() id="$2"; shift 2; cible=""; inc=()
while (( $# )); do case "$1" in --target) cible="$2"; shift 2;; --include) inc+=("$2"); shift 2;; *) shift;; esac; done 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_ / "pg_dump", '#!/bin/bash\necho dump\n')
executable(bin_ / "dropdb", f'#!/bin/bash\necho "dropdb $*" >> "{log}/pg"\n') executable(bin_ / "dropdb", f'#!/bin/bash\necho "dropdb $*" >> "{log}/pg"\n')
rendu = jinja2.Environment(undefined=jinja2.StrictUndefined).from_string( executable(bin_ / "setops-temoins", f'#!/bin/bash\nexec python3 "{COMPARATEUR}" "$@"\n')
GABARIT.read_text()).render(
client_backup_repo="sftp:restic@depot:hote", def rendre(nom: str, jeux: list[dict]) -> Path:
inventory_hostname="hote", rendu = jinja2.Environment(undefined=jinja2.StrictUndefined).from_string(
client_backup_restauration_marques=str(d / "marques"), GABARIT.read_text()).render(
client_backup_restauration_mise_de_cote=str(d / "mis-de-cote"), client_backup_repo="sftp:restic@depot:hote",
client_backup_staging=str(staging), inventory_hostname="hote",
client_backup_jobs=[{"nom": "courriel"}, {"nom": "postgresql"}], client_backup_restauration_marques=str(d / "marques"),
client_backup_attente_verrou="30m", client_backup_restauration_mise_de_cote=str(d / "mis-de-cote"),
).replace("/etc/ssh/ssh_host_ed25519_key.pub", str(cle)) \ client_backup_staging=str(staging),
.replace("/etc/setops/restic.pass", str(d / "restic.pass")) client_backup_jobs=jeux,
script = d / "setops-restaurer" client_backup_attente_verrou="30m",
executable(script, rendu) 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']}"} env = {**os.environ, "PATH": f"{bin_}:{os.environ['PATH']}"}
def lancer(*args: str, **extra: str) -> subprocess.CompletedProcess: def lancer(*args: str, outil: Path = script, **extra: str) -> subprocess.CompletedProcess:
return subprocess.run(["bash", str(script), *args], env={**env, **extra}, return subprocess.run(["bash", str(outil), *args], env={**env, **extra},
capture_output=True, text=True) capture_output=True, text=True)
r = lancer("choisir") 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()})") 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") 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") r = lancer("base", "nextcloud")
section = (log / "section.sql").read_text() if (log / "section.sql").exists() else "" 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]})") 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") r = lancer("base", "inexistante")
verifier(r.returncode == 0 and "ABSENTE" in r.stdout, "une base absente de l'instantane est dite, pas inventee") 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", "courriel", "restaure", "chasse09")
lancer("acter", jeu, "restaure", "avant01") lancer("acter", "postgresql", "restaure", "avant01")
r = lancer("garde") r = lancer("garde")
verifier(r.returncode == 0, "la sauvegarde reprend une fois chaque jeu acte") 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(): for f in (d / "marques").iterdir():
f.unlink() f.unlink()
r = lancer("garde", SETOPS_TEST_DEPOT_ABSENT="1") 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 »") "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: def main() -> int:
couverture() couverture()
outil() outil()
sauvegarde()
copie_a_froid()
if ECHECS: if ECHECS:
print(f"\n{len(ECHECS)} echec(s).") print(f"\n{len(ECHECS)} echec(s).")
return 1 return 1

View file

@ -0,0 +1,357 @@
#!/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}"]
# L'index que le compacteur fusionne n'est pas un morceau : sa disparition ne compte pas.
ecrire(avant / "chunks" / "index" / "index_1" / "1791478851-obs-01.tsdb.gz", "index")
rc, out = lancer(argv)
verifier(rc == 0 and "1 morceau(x) nouveau(x)" in out and "retire" not in out,
f"Loki restaure, des journaux arrives depuis, un index compacte : sans ecart ni retrait ({out.strip()[-100:]})")
(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())

View file

@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""La sonde du depot juge le PLUS RECENT instantane, pas le premier groupe que restic rend.
POURQUOI (2026-10-08). `restic snapshots --latest 1` rend le dernier instantane de chaque
groupe — machine ET liste de chemins. Ajouter un jeu a une machine change sa liste : deux
groupes. La sonde gardait `d[0]` : sur site-mon-01, au lendemain de l'arrivee de prometheus,
loki et icinga, elle annoncait « EN RETARD : 26 h » sur une sauvegarde de 2 h 30, et aurait
annonce « PERIME » a 50 h. La sonde rendue depuis son vrai gabarit, `restic` et `curl` doubles.
"""
from __future__ import annotations
import datetime
import json
import os
import stat
import subprocess
import sys
import tempfile
from pathlib import Path
import jinja2
RACINE = Path(__file__).resolve().parents[2]
GABARIT = RACINE / "roles/client_backup/templates/verifier-mon-depot.sh.j2"
ECHECS: list[str] = []
def verifier(cond: bool, msg: str) -> None:
print(("OK " if cond else "ECHEC ") + msg)
if not cond:
ECHECS.append(msg)
def executable(p: Path, texte: str) -> None:
p.write_text(texte)
p.chmod(p.stat().st_mode | stat.S_IXUSR)
def lancer(d: Path, groupes: list[dict]) -> dict:
bin_ = d / "bin"
bin_.mkdir(exist_ok=True)
(d / "groupes.json").write_text(json.dumps(groupes))
executable(bin_ / "restic", f"""#!/bin/bash
[[ "$1" == --retry-lock ]] && shift 2
case "$1" in
snapshots) cat "{d}/groupes.json" ;;
stats) echo '{{"total_size": 1234}}' ;;
ls) echo '{{"struct_type":"node","type":"file"}}' ;;
esac
""")
executable(bin_ / "curl", f"""#!/bin/bash
while (( $# )); do [[ "$1" == -d ]] && echo "$2" > "{d}/verdict.json"; shift; done
echo '{{"results":[{{"code": 200}}]}}'
""")
(d / "mdp").write_text("x")
rendu = jinja2.Environment(undefined=jinja2.StrictUndefined).from_string(GABARIT.read_text()).render(
client_backup_attente_verrou="30m", client_backup_repo="sftp:r@d:h", client_backup_icinga_hote="mon-01",
domaine_interne="exemple.internal", client_backup_ttl_icinga=21600, client_backup_age_warn_h=26,
client_backup_age_crit_h=50, inventory_hostname="site-mon-01", client_backup_jobs=[{"chemins": [str(d / "x")]}],
client_backup_ca_verification="/dev/null", client_backup_icinga_utilisateur="u",
).replace("/etc/setops/icinga-api.pass", str(d / "mdp")).replace("/etc/setops/restic.pass", str(d / "mdp"))
executable(d / "sonde.sh", rendu)
subprocess.run(["bash", str(d / "sonde.sh")], env={**os.environ, "PATH": f"{bin_}:{os.environ['PATH']}"},
capture_output=True, text=True)
return json.loads((d / "verdict.json").read_text()) if (d / "verdict.json").exists() else {}
def heure(il_y_a_h: float) -> str:
t = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=il_y_a_h)
return t.astimezone().isoformat(timespec="microseconds")[:-6] + "123" + t.astimezone().isoformat()[-6:]
def main() -> int:
with tempfile.TemporaryDirectory() as d:
v = lancer(Path(d), [{"time": heure(30), "paths": ["/var/backups/setops/postgresql"]},
{"time": heure(1), "paths": ["/var/backups/setops/postgresql", "/var/backups/setops/loki"]}])
verifier(v.get("exit_status") == 0 and "OK" in v.get("plugin_output", ""),
f"deux groupes, l'ancien en premier : la sonde juge le plus recent ({v.get('plugin_output')})")
with tempfile.TemporaryDirectory() as d:
v = lancer(Path(d), [{"time": heure(30), "paths": ["/a"]}])
verifier(v.get("exit_status") == 1 and "EN RETARD" in v.get("plugin_output", ""),
f"un seul groupe, vieux de 30 h : toujours EN RETARD ({v.get('plugin_output')})")
if ECHECS:
print(f"\n{len(ECHECS)} echec(s).")
return 1
print("\nLa sonde juge le plus recent instantane, quel que soit l'ordre des groupes.")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""`verrou-runner.yml` : ce que le runner installe, une version par bibliotheque, coherent avec
les epingles — et plus aucune resolution contre PyPI ou Galaxy dans les taches.
POURQUOI (2026-10-09). Le runner de Technolibre portait deux versions de six bibliotheques :
`pip download` resolvait contre PyPI a chaque passage, et PyPI avait bouge entre
l'insemination et le montage. Le verrou nomme chaque fichier ; ce test garde qu'il reste un
verrou (une version par bibliotheque), que ses roues vont au Python declare, et que les
epingles ecrites ailleurs disent la meme chose que lui.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
import jinja2
import yaml
RACINE = Path(__file__).resolve().parents[2]
ECHECS: list[str] = []
def verifier(cond: bool, msg: str) -> None:
print(("OK " if cond else "ECHEC ") + msg)
if not cond:
ECHECS.append(msg)
def norme(nom: str) -> str:
return re.sub(r"[-_.]+", "_", nom).lower()
def main() -> int:
v = yaml.safe_load((RACINE / "verrou-runner.yml").read_text())
roues, colls = v["python"], v["collections"]
for e in roues + colls:
verifier(e["url"].endswith("/" + e["nom"]) and re.fullmatch(r"[0-9a-f]{64}", str(e["sha256"])) is not None,
f"{e['nom']} : url qui le nomme, sha256 bien formee")
versions: dict[str, set[str]] = {}
for e in roues:
dist, ver = e["nom"].split("-")[0], e["nom"].split("-")[1]
versions.setdefault(norme(dist), set()).add(ver)
doubles = {d: sorted(vs) for d, vs in versions.items() if len(vs) > 1}
verifier(not doubles, f"une seule version par bibliotheque ({len(versions)} bibliotheques){' : ' + str(doubles) if doubles else ''}")
cible = "cp" + str(v["python_cible"]).replace(".", "")
hors = [e["nom"] for e in roues if e["nom"].split("-")[2].startswith("cp") and "abi3" not in e["nom"]
and e["nom"].split("-")[2] != cible]
verifier(not hors, f"les roues compilees vont a Python {v['python_cible']} ({cible}){' : ' + str(hors) if hors else ''}")
epingles = {}
for l in (RACINE / "requirements-python.txt").read_text().splitlines():
m = re.match(r"^\s*([A-Za-z0-9_.-]+)==([^\s#]+)", l)
if m:
epingles[norme(m.group(1))] = m.group(2)
defauts = yaml.safe_load((RACINE / "roles/serveur_ops/defaults/main.yml").read_text())
m = re.fullmatch(r"\s*([A-Za-z0-9_.-]+)==(\S+)\s*", defauts["serveur_ops_ansible"])
verifier(m is not None, f"serveur_ops_ansible est une epingle exacte ({defauts['serveur_ops_ansible']})")
if m:
epingles[norme(m.group(1))] = m.group(2)
for dist, ver in sorted(epingles.items()):
verifier(versions.get(dist) == {ver}, f"{dist}=={ver} epingle, et c'est la version du verrou ({sorted(versions.get(dist, []))})")
req = yaml.safe_load((RACINE / "requirements.yml").read_text())["collections"]
attendus = sorted(f"{c['name'].replace('.', '-')}-{c['version']}.tar.gz" for c in req)
verifier(sorted(e["nom"] for e in colls) == attendus, f"les collections du verrou sont celles de requirements.yml ({attendus})")
taches = [l for l in (RACINE / "roles/serveur_ops/tasks/main.yml").read_text().splitlines()
if not l.lstrip().startswith("#")]
texte = "\n".join(taches)
verifier('"download"' not in texte and "collection download" not in texte,
"plus aucune resolution contre PyPI ou Galaxy dans serveur_ops (pip / collection download)")
# Une copie fichier par fichier ne cree pas son dossier (2026-10-09 : reconstruction de
# Technolibre arretee sur un runner neuf). Chaque dossier vise doit etre assure AVANT.
assures: set[str] = set()
for t in yaml.safe_load((RACINE / "roles/serveur_ops/tasks/main.yml").read_text()):
f = t.get("ansible.builtin.file") or {}
if f.get("state") == "directory":
assures.add(f["path"])
c = t.get("ansible.builtin.copy") or {}
if str(c.get("dest", "")).endswith("/{{ item.nom }}"):
dossier = c["dest"].rsplit("/", 1)[0]
verifier(dossier in assures, f"« {t['name']} » : son dossier est cree avant ({dossier})")
# LE FICHIER RENDU, PAS LE MODELE (2026-10-09). `'\\1'` dans un bloc `|` restait `\1` :
# `ansible-galaxy` refusait « Non integer values in LooseVersion ('\1') », et la
# reconstruction de Technolibre s'est arretee. Rendu ici avec Jinja2 seul, comme le runner.
tache = next(t for t in yaml.safe_load((RACINE / "roles/serveur_ops/tasks/main.yml").read_text())
if t.get("name") == "Ecrire la liste hors ligne des collections du verrou")
try:
rendu = yaml.safe_load(jinja2.Template(tache["ansible.builtin.copy"]["content"]).render(
serveur_ops_verrou={"collections": colls}))["collections"]
obtenues = sorted(f"{c['name']}={c['version']}" for c in rendu)
except Exception as e: # un filtre propre a Ansible, une syntaxe cassee : c'est un echec
obtenues = [f"rendu impossible : {e}"]
voulues = sorted(f"{c['name'].replace('.', '-')}-{c['version']}.tar.gz={c['version']}" for c in req)
verifier(obtenues == voulues, f"la liste hors ligne rendue porte les vraies versions ({obtenues})")
verifier("verrou-runner.yml" in (RACINE / "roles/serveur_artefacts/tasks/main.yml").read_text(),
"le site tient le verrou du runner")
if ECHECS:
print(f"\n{len(ECHECS)} echec(s).")
return 1
print("\nLe verrou nomme chaque fichier du runner, une version par bibliotheque, et le site le tient.")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -62,8 +62,21 @@ def tunnels(inclure_vides: bool = False) -> list[dict]:
"pairs": {str(p["nom"]): p for p in (site.get("pairs") or [])}}) "pairs": {str(p["nom"]): p for p in (site.get("pairs") or [])}})
for nom_depot, _prefixe, _n in devis_reseau.decouvrir_du_site(): for nom_depot, _prefixe, _n in devis_reseau.decouvrir_du_site():
base = RACINE.parent / nom_depot base = RACINE.parent / nom_depot
registre = charger_acces(base / "plan" / "acces.yml") # LA FACE RESEAU D'ABORD (2026-10-05, etape 3) : le locataire publie ses pairs et son
index = charger_nomenclature(base / "plan" / "nomenclature.yml").get("index") # 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 # 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 # 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. # probleme d'oeuf et de poule, et c'est ce que le premier essai a montre.

79
verrou-runner.yml Normal file
View file

@ -0,0 +1,79 @@
---
# CE QUE LE RUNNER INSTALLE, A L'OCTET PRES — tenu par le site, verifie a la reception.
#
# POURQUOI (2026-10-09). A chaque reconstruction, le runner sortait sur Internet : `pip
# download` interrogeait PyPI, et un runner neuf tirait ses collections de Galaxy. Seules
# deux bibliotheques etaient epinglees ; `ansible-core` etait borne (>=2.18,<2.19), le reste
# libre. Mesure sur le runner de Technolibre : son depot de roues portait DEUX versions de
# six bibliotheques (cryptography 50.0.1 et 50.0.2, urllib3 2.7.0 et 2.8.0...) — PyPI avait
# bouge entre l'insemination et le montage, et personne n'avait choisi celle qui tournait.
#
# CE VERROU EST CE QUI TOURNE : les quinze roues installees dans le venv de ce runner (pip
# freeze), et les trois collections qu'il a recues, avec leurs sommes, releves le
# 2026-10-09. Chaque URL a ete telechargee depuis le poste et rend exactement sa somme.
#
# QUI LE LIT : `serveur_artefacts` (le site tient ces fichiers dans son depot de binaires),
# `serveur_ops` (le runner les prend au site, ne depose QUE ceux-ci, installe hors ligne),
# P70 (le site tient tout ce que le runner va chercher), `test_verrou_runner.py`.
#
# MONTER UNE VERSION est un geste delibere : remplacer la ligne (nom, url, sha256), et les
# epingles de `requirements-python.txt`, `serveur_ops_ansible` et `requirements.yml`, que
# le test confronte a ce verrou.
# Le Python pour lequel les roues sont faites (`cp313`) ; la cible doit le porter.
python_cible: "3.13"
python:
- nom: ansible_core-2.18.19-py3-none-any.whl
url: https://files.pythonhosted.org/packages/py3/a/ansible-core/ansible_core-2.18.19-py3-none-any.whl
sha256: 00f53c10d97ce001f6e49857820901b3e8d2f234cb9f5fd9fdf8092c14109fa0
- nom: certifi-2026.7.22-py3-none-any.whl
url: https://files.pythonhosted.org/packages/py3/c/certifi/certifi-2026.7.22-py3-none-any.whl
sha256: 62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775
- nom: cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
url: https://files.pythonhosted.org/packages/cp313/c/cffi/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
sha256: a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2
- nom: charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
url: https://files.pythonhosted.org/packages/cp313/c/charset-normalizer/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl # yamllint disable-line rule:line-length
sha256: 7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4
- nom: cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl
url: https://files.pythonhosted.org/packages/cp311/c/cryptography/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl
sha256: 9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079
- nom: idna-3.20-py3-none-any.whl
url: https://files.pythonhosted.org/packages/py3/i/idna/idna-3.20-py3-none-any.whl
sha256: ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c
- nom: jinja2-3.1.6-py3-none-any.whl
url: https://files.pythonhosted.org/packages/py3/j/jinja2/jinja2-3.1.6-py3-none-any.whl
sha256: 85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67
- nom: markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
url: https://files.pythonhosted.org/packages/cp313/m/markupsafe/markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl # yamllint disable-line rule:line-length
sha256: 434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6
- nom: packaging-26.3-py3-none-any.whl
url: https://files.pythonhosted.org/packages/py3/p/packaging/packaging-26.3-py3-none-any.whl
sha256: d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
- nom: proxmoxer-2.2.0-py3-none-any.whl
url: https://files.pythonhosted.org/packages/py3/p/proxmoxer/proxmoxer-2.2.0-py3-none-any.whl
sha256: 7e4431fd38e1a9321eedf6d860ddc373fc2b29ce1845fa8b072d7580d99f6b90
- nom: pycparser-3.1-py3-none-any.whl
url: https://files.pythonhosted.org/packages/py3/p/pycparser/pycparser-3.1-py3-none-any.whl
sha256: f09d358c840bd147b79e55f2bc494f18ea869dc897f5852a8f5766b74f787882
- nom: pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
url: https://files.pythonhosted.org/packages/cp313/p/pyyaml/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
sha256: 0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6
- nom: requests-2.32.3-py3-none-any.whl
url: https://files.pythonhosted.org/packages/py3/r/requests/requests-2.32.3-py3-none-any.whl
sha256: 70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6
- nom: resolvelib-1.0.1-py2.py3-none-any.whl
url: https://files.pythonhosted.org/packages/py2.py3/r/resolvelib/resolvelib-1.0.1-py2.py3-none-any.whl
sha256: d2da45d1a8dfee81bdd591647783e340ef3bcb104b54c383f70d422ef5cc7dbf
- nom: urllib3-2.8.0-py3-none-any.whl
url: https://files.pythonhosted.org/packages/py3/u/urllib3/urllib3-2.8.0-py3-none-any.whl
sha256: 0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3
collections:
- nom: ansible-posix-1.6.2.tar.gz
url: https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections/artifacts/ansible-posix-1.6.2.tar.gz
sha256: 3c6b4d4f326cb42490b75644179e809ed09f3040ddaf0763bd6ea9e0f2ef8c47
- nom: community-general-10.3.0.tar.gz
url: https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections/artifacts/community-general-10.3.0.tar.gz
sha256: f1df87cca61c7c22aa1d66fbb3864185da71d0141f825a1385b8c4b7a635b129
- nom: community-postgresql-3.10.2.tar.gz
url: https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections/artifacts/community-postgresql-3.10.2.tar.gz
sha256: f0ad8976e502d28c901b8bbeb5a4a85a82f86e28fac245c4caa3d60a618e14e2