Compare commits
21 commits
v2026.10.0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ee0e33d452 | |||
| dd4ca09245 | |||
| 162604ad24 | |||
| 34e961fb61 | |||
| 422b68846b | |||
| 55f42add15 | |||
| 4ebca13058 | |||
| 279e583701 | |||
| 3afb756993 | |||
| c7378ed7f7 | |||
| 7a4c55bfc9 | |||
| 0f7c89fbb5 | |||
| 9ca9a5ffe1 | |||
| 2dce6eabd5 | |||
| 479ec3cc7d | |||
| 9a4b63544e | |||
| 3e2846cc5f | |||
| cca22f47ca | |||
| 4660a8892a | |||
| 6141bc4b2f | |||
| e8907285d5 |
58 changed files with 3518 additions and 796 deletions
824
CHANGELOG.md
824
CHANGELOG.md
|
|
@ -1,5 +1,829 @@
|
||||||
# CHANGELOG — Set-OPS
|
# CHANGELOG — Set-OPS
|
||||||
|
|
||||||
|
## 2026-10-10 (125) — Plus aucune machine ne sort vers Internet sans un rôle qui le dise
|
||||||
|
|
||||||
|
**94 preuves.** `make verifier` passe, `make test` aussi avec le nouveau
|
||||||
|
`test_conditions_site.py` ; lancé sur l'ancien code, ce test échoue sur ses quatre
|
||||||
|
contrôles. Le plan de la frontière est vide après application (308 règles inchangées).
|
||||||
|
|
||||||
|
### Une sortie générale qui ne servait plus à ce qu'elle disait
|
||||||
|
|
||||||
|
Le socle donnait à chaque machine des trois écosystèmes une sortie 80/443 vers tout
|
||||||
|
Internet, « pour les dépôts apt ». Sur les 26 machines des locataires, toutes les sources
|
||||||
|
apt sont pourtant en `http://` et passent par le cache du site ; le site vise son propre
|
||||||
|
cache depuis le 2026-09-14. Le journal de la frontière, sur une semaine, et trois relevés
|
||||||
|
de sa table d'états (20 min, 1 h, puis le cache apt du site pour dater les cas rares) ont
|
||||||
|
attribué chaque sortie restante à sa machine :
|
||||||
|
|
||||||
|
- les cartes de rspamd (maps.rspamd.com, ~2 700 connexions par jour et par locataire) et
|
||||||
|
la carte SURBL (sa-update.surbl.org, ~245), depuis les deux `edge-mta-01` ;
|
||||||
|
- grafana.com (versions) et stats.grafana.org (compteurs d'usage), depuis Grafana, Loki,
|
||||||
|
et surtout Alloy, présent sur **toutes** les machines ;
|
||||||
|
- collabora.co.uk, la vérification de version de Collabora toutes les 10 heures ;
|
||||||
|
- le repère du génome de `site-ops-01` vers eregion.
|
||||||
|
|
||||||
|
PyPI, Galaxy, S3 et GitHub n'apparaissent plus après le 2026-10-09 au matin (entrée 121).
|
||||||
|
|
||||||
|
### Coupé à la source, déclaré dans le rôle, retiré du socle
|
||||||
|
|
||||||
|
La télémétrie est coupée là où elle naît : `--disable-reporting` pour Alloy, `analytics:
|
||||||
|
reporting_enabled: false` pour Loki, `GF_ANALYTICS_*`, Gravatar et le fil de nouvelles
|
||||||
|
pour Grafana, `fetch_update_check=0` et `allow_update_popup=false` pour Collabora. Les
|
||||||
|
mises à jour arrivent par apt, comme le reste.
|
||||||
|
|
||||||
|
Ce qui sert vraiment est déclaré dans le rôle qui en a besoin : rspamd sort en 443 et 80
|
||||||
|
(son repli local fige les listes à la version du paquet ; Daniel a choisi de garder des
|
||||||
|
cartes fraîches, comme Postfix garde son 25), `serveur_ops_site` en 443 pour sa sonde.
|
||||||
|
La raison de la sortie du cache du site nomme maintenant le dépôt de binaires.
|
||||||
|
|
||||||
|
La sortie du socle devient conditionnelle (`seulement_si: artefacts_amorcage` vide) : elle
|
||||||
|
ne vaut plus que pour un écosystème sans aucune source d'artefacts.
|
||||||
|
|
||||||
|
### Le devis du site ignorait les conditions
|
||||||
|
|
||||||
|
`make flux` retirait la sortie des machines du site, le devis de la frontière la gardait :
|
||||||
|
la boucle du site n'évaluait pas `seulement_si`, celle des locataires si. Elle l'évalue
|
||||||
|
désormais sur les variables d'hôte de l'inventaire du site (inventaire illisible : la règle
|
||||||
|
reste émise). Second effet, voulu : les règles 465/587 vers le Postfix du site, qui n'écoute
|
||||||
|
que sur 25 (`serveur_postfix_submission_actif` vaut `false`), sont retirées sur six pattes.
|
||||||
|
|
||||||
|
`test_conditions_site.py` refuse qu'un rôle universel (socle, durcissement, `client_*`)
|
||||||
|
ouvre Internet sans condition, et que la boucle du site cesse d'évaluer les conditions.
|
||||||
|
|
||||||
|
### Déployé et mesuré
|
||||||
|
|
||||||
|
Configurations sur les 26 machines des locataires et les 9 du site, sans échec ; vérifiées
|
||||||
|
sur les processus en cours. Les hyperviseurs portent aussi `client_journal` mais n'ont pas
|
||||||
|
été touchés : leur configuration Alloy et leur épinglage n'ont jamais reçu le changement du
|
||||||
|
2026-10-03, écart antérieur à traiter à part. Frontière : 7 créations (deux alias rspamd,
|
||||||
|
quatre règles rspamd, la sonde du génome), 32 retraits, relus. Aucun blocage vers Internet
|
||||||
|
dans le journal après l'application, aucune erreur de carte dans rspamd.
|
||||||
|
|
||||||
|
**Limites.** Trois destinations du site restent non attribuées (Leaseweb 89.149.222.99,
|
||||||
|
Gravatar, freebsd.org, une ou deux fois par jour) ; si elles venaient d'une autre machine
|
||||||
|
que le cache, elles sont désormais bloquées et le journal nommera la machine. Deux
|
||||||
|
connexions isolées par reconstruction (Google pendant l'installation d'Icinga sur `mon-01`,
|
||||||
|
Cloudflare pendant celle de Nextcloud sur `collab-01`) seront bloquées à la prochaine : la
|
||||||
|
reconstruction dira si elles comptaient.
|
||||||
|
|
||||||
|
## 2026-10-09 (124) — La raison du flux doit survivre au chargement du pare-feu
|
||||||
|
|
||||||
|
**94 preuves.** Les huit tests de libellés passent avec `make test`, ainsi que
|
||||||
|
`make flux-verifier` et `make syntaxe`. `make lint` passe avec trois avertissements
|
||||||
|
Jinja dans des playbooks de devis non modifiés. Le rapport du harnais est conservé dans
|
||||||
|
`docs/audit/preuve-2026-10-09.md` ; sa création porte les pièces d'audit à 52, reportées
|
||||||
|
dans la carte d'orientation pour P48.
|
||||||
|
|
||||||
|
### Une raison au registre, absente du champ que l'opérateur lit
|
||||||
|
|
||||||
|
Le registre expliquait chaque flux, mais nftables ne recevait que des commentaires de
|
||||||
|
fichier `#`, perdus au chargement. Proxmox ne gardait que le pair ou le rôle, OPNsense
|
||||||
|
l'identifiant technique et le rôle. Lire les corps envoyés aux API et le rendu nftables
|
||||||
|
a montré où la justification disparaissait.
|
||||||
|
|
||||||
|
Les règles reçoivent maintenant leur commentaire natif : rôle et raison du registre,
|
||||||
|
pair nommé pour Proxmox, clé de rapprochement inchangée pour OPNsense. Le socle nftables
|
||||||
|
et son gabarit de repli décrivent aussi leurs règles communes. Les textes sont ramenés
|
||||||
|
sur une ligne ; les limites de longueur préservent les caractères UTF-8 et la clé
|
||||||
|
OPNsense. Une raison vide est refusée à la validation.
|
||||||
|
|
||||||
|
### Corriger une description ne doit pas remplacer une règle
|
||||||
|
|
||||||
|
Les devis ignoraient les changements de commentaire : enrichir la création seule aurait
|
||||||
|
laissé toutes les règles déjà posées avec leurs anciens libellés. Ils détectent désormais
|
||||||
|
ces écarts, mettent à jour le seul champ sur la règle existante, puis le relisent. Les
|
||||||
|
tests gardent l'activation et la politique, exigent un second passage sans changement,
|
||||||
|
et refusent une API qui annonce le succès sans conserver l'écriture. Les descriptions
|
||||||
|
de NAT et de redirection, et les affectations de groupes Proxmox, sont également couvertes.
|
||||||
|
|
||||||
|
### Ce qui a été mesuré avant l'application
|
||||||
|
|
||||||
|
Le devis OPNsense relu annonce **335 descriptions à corriger, aucune création et aucun
|
||||||
|
retrait**. Le devis Proxmox ne peut pas lire le cluster : connexion refusée, même hors
|
||||||
|
du bac à sable. Les 36 fichiers nftables du site et des trois instances ont été
|
||||||
|
régénérés et relus : les règles sont identiques après retrait des annotations dans
|
||||||
|
la comparaison. Aucun pare-feu n'a été rechargé.
|
||||||
|
|
||||||
|
La vérification native `nft --check` en espace réseau isolé n'a pas pu s'exécuter : le
|
||||||
|
poste refuse la création de cet espace, y compris hors du bac à sable. L'écart ancien
|
||||||
|
sur `sorties_externes` dans le JSON de connectivité du laboratoire est signalé et laissé
|
||||||
|
intact ; aucun plan ni inventaire appliqué n'a été modifié.
|
||||||
|
|
||||||
|
### Une relecture d'API compare les valeurs, pas la langue des menus
|
||||||
|
|
||||||
|
L'application confirmée par l'opérateur a d'abord arrêté sa comparaison OPNsense après
|
||||||
|
une seule description : l'API venait aussi de remplir son audit (auteur et date).
|
||||||
|
Les lectures suivantes rendaient certains choix de menus en français ou en anglais.
|
||||||
|
Comparer les réponses brutes faisait donc croire à un changement de règle. La
|
||||||
|
comparaison a conservé les identifiants et les sélections de tous les choix, ainsi
|
||||||
|
que les notes d'audit ; seuls les textes traduits et l'audit automatique de création
|
||||||
|
et de mise à jour ont été neutralisés, en plus du champ descriptif attendu.
|
||||||
|
|
||||||
|
**335 descriptions OPNsense appliquées et relues** : tous les paramètres de filtrage
|
||||||
|
restent identiques à la sauvegarde. Les quatre rechargements (alias, filtre, NAT
|
||||||
|
sortant et redirections) ont répondu en succès ; le devis final ne porte plus d'écart.
|
||||||
|
|
||||||
|
### Le cluster se lit depuis son runner, et nftables se vérifie sur les VM
|
||||||
|
|
||||||
|
Le refus d'accès direct à Proxmox n'était pas une indisponibilité du cluster : son
|
||||||
|
runner y accède. Le code local a été exécuté en mémoire depuis ce runner, sans
|
||||||
|
publication ni remplacement de son dépôt. **108 commentaires de règles et 90
|
||||||
|
commentaires d'affectations**, sur les 26 VM des deux locataires, ont été appliqués
|
||||||
|
et relus ; tous les autres paramètres, dont l'activation, sont identiques. Le devis
|
||||||
|
final est sans écart.
|
||||||
|
|
||||||
|
Sur les **neuf VM de SITE-Chezlepro**, `nft --check` passe. Une compilation dans un
|
||||||
|
espace réseau éphémère de chaque VM a permis de comparer le candidat aux règles
|
||||||
|
réellement chargées avant écriture. Les **237 règles** portent désormais leur
|
||||||
|
commentaire natif ; la relecture du noyau confirme le même filtrage et les commentaires
|
||||||
|
attendus. Une nouvelle connexion SSH a réussi après chaque chargement, le runner en
|
||||||
|
dernier. Les fichiers et règles précédents sont conservés sur chaque VM sous
|
||||||
|
`/var/backups/setops-libelles-20261009/` ; les relevés et sauvegardes d'API sont hors
|
||||||
|
dépôt, sur le poste, dans `/tmp/setops-libelles-application-20261009/`.
|
||||||
|
|
||||||
|
Le lot confirmé excluait le déploiement nftables des **26 VM des locataires** (clés
|
||||||
|
d'hôte SSH changées, à vérifier) et du **laboratoire** (injoignable). Ils restent
|
||||||
|
non déployés. Le moteur et les fichiers générés des deux locataires et du site doivent
|
||||||
|
être publiés ensemble sur les deux forges (`make publier`) pour qu'une reconstruction
|
||||||
|
conserve ces libellés.
|
||||||
|
|
||||||
|
## 2026-10-09 (123) — Chezlepro reconstruite sur `34e961f`, d'une traite : les deux locataires sans PyPI ni Galaxy
|
||||||
|
|
||||||
|
**94 preuves.** `make reconstruire-locataire TENANT=OPS-Chezlepro` (43 min, journal
|
||||||
|
`OPS-Chezlepro/logs/reconstruction-20261009-122645.log`), sans arrêt : `34e961f` tiré ; les 9
|
||||||
|
détenteurs d'état déposent leur instantané `avant-raser` (12:26:49) ; 10 jeux restaurés,
|
||||||
|
témoins sans écart sur les 9 ; `valider` passé ; pare-feu 13/13 activés d'un coup,
|
||||||
|
`connectivite` saine partout, 0 critique.
|
||||||
|
|
||||||
|
Le runner : roues et collections prises au site, replis sautés ; relevé sur `ops-01`, 15
|
||||||
|
roues déposées, `pip freeze` égal au verrou, collections 1.6.2 / 10.3.0 / 3.10.2. Journal de
|
||||||
|
montage : aucun `failed` ; replis de `serveur_ops` et des applications de Nextcloud sautés.
|
||||||
|
|
||||||
|
**Les deux locataires sont reconstruits sur `34e961f`** (entrées (122) et (123)). Pour
|
||||||
|
Technolibre, sauvegarder, raser et créer ont tourné sur `55f42ad` ; les commits suivants ne
|
||||||
|
touchent que `serveur_ops`, son test et ce journal, et tout ce qui suit l'insémination a
|
||||||
|
tourné sur `34e961f`. Les trois dépendances Internet mesurées le 2026-10-05 (PyPI, Galaxy,
|
||||||
|
app store de Nextcloud) ne sont plus demandées par une reconstruction de locataire. Toujours
|
||||||
|
non mesuré : un montage avec la sortie Internet coupée.
|
||||||
|
|
||||||
|
## 2026-10-09 (122) — Technolibre reconstruite sur `34e961f` : le runner ne demande plus rien à PyPI ni à Galaxy
|
||||||
|
|
||||||
|
**94 preuves.** `make reconstruire-locataire TENANT=OPS-Technolibre`, en trois passages : à
|
||||||
|
10:40 (sauvegarder, raser, créer, puis arrêt à l'insémination sur `55f42ad`), à 11:23 (reprise
|
||||||
|
`DEPUIS=inseminer` sur `422b688`, arrêt aux collections), et à 11:36 sur `34e961f`, jusqu'au
|
||||||
|
bout en 36 min (journal `OPS-Technolibre/logs/reconstruction-20261009-113649.log`). Les deux
|
||||||
|
arrêts et leurs corrections sont dans l'entrée (121).
|
||||||
|
|
||||||
|
### Le runner, monté depuis le site seulement
|
||||||
|
|
||||||
|
- Insémination : les 15 roues et les 3 collections **prises au dépôt du site** ; les deux
|
||||||
|
replis sautés, aucun message « absentes du dépôt du site » ; Ansible, les bibliothèques et
|
||||||
|
les collections installés hors ligne.
|
||||||
|
- Relevé sur le runner reconstruit : 15 roues déposées, `pip freeze` égal au verrou ligne à
|
||||||
|
ligne (`ansible-core` 2.18.19 … `urllib3` 2.8.0) ; collections `ansible.posix` 1.6.2,
|
||||||
|
`community.general` 10.3.0, `community.postgresql` 3.10.2.
|
||||||
|
- Journal de montage (`/opt/setops/monter-flotte.log`) : aucun `failed` ; les replis de
|
||||||
|
`serveur_ops` et ceux des applications de Nextcloud (`user_oidc`, `richdocuments`) sautés.
|
||||||
|
|
||||||
|
### Le reste, comme d'habitude
|
||||||
|
|
||||||
|
`valider` passé ; pare-feu 13/13 activés d'un coup, `connectivite` saine partout, 0 critique
|
||||||
|
dans Icinga. Les 10 jeux restaurés depuis l'instantané `avant-raser` de 10:40, chacun encore
|
||||||
|
au dépôt ; témoins sans écart sur les 9 détenteurs d'état.
|
||||||
|
|
||||||
|
### Ce qui reste
|
||||||
|
|
||||||
|
Chezlepro, sur ce même commit (règle des deux locataires). PyPI, Galaxy et l'app store de
|
||||||
|
Nextcloud étaient les trois dépendances Internet mesurées le 2026-10-05 ; si Chezlepro passe
|
||||||
|
aussi, une reconstruction de locataire ne les demande plus. Non mesuré ici : un montage
|
||||||
|
**sans** accès Internet du tout (la preuve serait de couper la sortie, pas de lire les replis).
|
||||||
|
|
||||||
|
## 2026-10-09 (121) — Le runner prend ses roues et ses collections au site, plus à PyPI ni à Galaxy
|
||||||
|
|
||||||
|
**94 preuves.** Les deux dernières dépendances Internet d'une reconstruction, mesurées le
|
||||||
|
2026-10-05, se ferment comme l'app store de Nextcloud (entrée (117)) : **le site fournit
|
||||||
|
tous les artefacts**.
|
||||||
|
|
||||||
|
### Pourquoi un verrou
|
||||||
|
|
||||||
|
`serveur_ops` laissait `pip download` et `ansible-galaxy collection download` résoudre les
|
||||||
|
versions au moment du montage. Mesuré sur le runner de Technolibre : le cache tenait **deux
|
||||||
|
versions de six bibliothèques** (`cryptography` 50.0.1 et 50.0.2, `urllib3` 2.7.0 et
|
||||||
|
2.8.0…) : PyPI avait bougé entre l'insémination et le montage.
|
||||||
|
On ne peut pas déposer au site « ce que PyPI répondra demain ». D'où `verrou-runner.yml`, à
|
||||||
|
la racine : les **15 roues** (Python 3.13) et les **3 collections** qui tournent aujourd'hui
|
||||||
|
sur ce runner, chacune avec son URL amont et sa somme SHA-256. `ansible-core` passe de
|
||||||
|
`>=2.18,<2.19` à `==2.18.19`, la version déjà déployée : aucun changement de version sur
|
||||||
|
la flotte.
|
||||||
|
|
||||||
|
### Ce qui change
|
||||||
|
|
||||||
|
- **`serveur_artefacts`** lit le verrou et dépose les 18 fichiers au dépôt de binaires du
|
||||||
|
site (`/setops-binaires`), somme vérifiée au téléchargement.
|
||||||
|
- **`serveur_ops`** les prend **au site d'abord**, somme vérifiée ; un fichier absent ou
|
||||||
|
dont la somme diffère est pris sur Internet **en le disant** (« absentes du dépôt du
|
||||||
|
site … : prises sur Internet »), et sa somme est encore vérifiée. Plus aucun
|
||||||
|
`pip download` ni `collection download`. Seuls les fichiers du verrou sont déposés sur
|
||||||
|
le runner ; les roues d'anciennes versions sont retirées. Les collections s'installent
|
||||||
|
depuis un `requirements.yml` généré du verrou.
|
||||||
|
- **P70** exige les fichiers du verrou au dépôt de binaires, et que la tâche du site lise
|
||||||
|
le verrou. **`test_verrou_runner.py`** (dans `make test`) : URL et somme bien formées, une
|
||||||
|
seule version par bibliothèque, roues compilées pour le Python cible, épinglages de
|
||||||
|
`requirements-python.txt` et de `serveur_ops_ansible` égaux au verrou, collections
|
||||||
|
égales à `requirements.yml`, plus aucun téléchargement résolu dans `serveur_ops`.
|
||||||
|
|
||||||
|
### Témoins
|
||||||
|
|
||||||
|
Trois, chacun échoue comme attendu puis repasse une fois remis : l'ancien `serveur_ops`
|
||||||
|
(« download » encore présent) ; un verrou à deux `urllib3` (« une seule version par
|
||||||
|
bibliothèque … {'urllib3': ['2.7.0', '2.8.0']} ») ; P70 quand le site ne lit plus le
|
||||||
|
verrou. Le deuxième a d'abord fait planter le test : une somme fictive de zéros est lue
|
||||||
|
par YAML comme un entier ; le test convertit désormais en texte.
|
||||||
|
|
||||||
|
### Déployé au site, vérifié depuis un runner
|
||||||
|
|
||||||
|
`serveur_artefacts` au site, simulé puis appliqué : les 18 fichiers déposés sur
|
||||||
|
`site-cache-01`, aucun absent, rien d'autre de changé. Depuis `ops-01` (Technolibre), chaque
|
||||||
|
fichier demandé au dépôt de binaires rend la somme du verrou : **18 conformes, 0 écart**.
|
||||||
|
|
||||||
|
`serveur_ops` simulé sur ce même runner (`--check --diff`, 0 échec) : il retirerait les six
|
||||||
|
roues d'anciennes versions (`cryptography` 50.0.1, `urllib3` 2.7.0…). La simulation affichait
|
||||||
|
pourtant les 15 roues « absentes du dépôt du site » : `--check` ne télécharge rien, le cache
|
||||||
|
du contrôleur reste vide, et le message accusait le dépôt à tort. Les deux messages se taisent
|
||||||
|
désormais en simulation.
|
||||||
|
|
||||||
|
### La reconstruction de Technolibre s'arrête à l'insémination
|
||||||
|
|
||||||
|
Sur `55f42ad`, à 10:45 : les 15 roues sont bien prises **au dépôt du site** (repli sauté),
|
||||||
|
puis « Destination directory /opt/setops/.roues-hors-ligne does not exist ». L'ancienne
|
||||||
|
copie du cache entier (`src: dossier/`) créait sa destination ; la copie fichier par fichier
|
||||||
|
du verrou ne la crée pas. La simulation sur l'ancien `ops-01` ne pouvait pas le voir : le
|
||||||
|
dossier y existait, et les copies sont sautées en `--check`. Les deux dossiers (roues,
|
||||||
|
collections) sont désormais assurés avant la copie, et `test_verrou_runner.py` exige, pour
|
||||||
|
toute copie fichier par fichier, que son dossier soit créé avant (témoin : l'ancien code
|
||||||
|
échoue sur les deux copies).
|
||||||
|
|
||||||
|
Reprise sur `422b688`, 11:25 : les roues déposées, Ansible et les bibliothèques installés
|
||||||
|
hors ligne, les 3 collections prises **au site** (repli sauté) ; puis `ansible-galaxy` refuse :
|
||||||
|
« Non integer values in LooseVersion ('\1') ». Le `requirements.yml` généré portait
|
||||||
|
`version: \1` : dans un bloc `|`, le `'\\1'` de `regex_replace` garde ses deux barres
|
||||||
|
obliques inverses, et le remplacement rend un `\1` littéral. Rejoué sur le poste, la tâche
|
||||||
|
du rôle telle quelle rend trois `\1` ; remplacée par un découpage sans expression régulière (`replace('.tar.gz', '')` puis `split('-') | last`),
|
||||||
|
elle rend `1.6.2`, `10.3.0`, `3.10.2`, et `ansible-galaxy collection install` installe les
|
||||||
|
trois archives (sommes du verrou vérifiées). Le test vérifiait le modèle, jamais le fichier :
|
||||||
|
il **rend** désormais la liste avec Jinja2 et compare ses versions à `requirements.yml`.
|
||||||
|
|
||||||
|
Vu dans le même temps à la frontière : `site-pki-01` → `10.37.32.1:8006` bloqué, environ
|
||||||
|
1 270 fois par jour depuis au moins le 2026-10-07. C'est la sonde de connectivité
|
||||||
|
(`nftables_baseline`, entrée du 2026-10-05) qui vérifie chaque minute que la passerelle ne
|
||||||
|
répond **pas** sur l'API Proxmox ; le blocage est le résultat attendu.
|
||||||
|
|
||||||
|
### Pas encore fait
|
||||||
|
|
||||||
|
Reprendre la reconstruction de Technolibre (`DEPUIS=inseminer`), puis Chezlepro, pour prouver
|
||||||
|
qu'aucun montage ne sort vers PyPI ni Galaxy.
|
||||||
|
|
||||||
|
## 2026-10-09 (120) — Chezlepro reconstruite sur `3afb756` : les deux locataires sur le même commit
|
||||||
|
|
||||||
|
**94 preuves.** `make reconstruire-locataire TENANT=OPS-Chezlepro` (44 min, journal
|
||||||
|
`OPS-Chezlepro/logs/reconstruction-20261009-085246.log`), sans arrêt : `3afb756` tiré ; les 9
|
||||||
|
détenteurs d'état déposent leur instantané `avant-raser` ; 10 jeux restaurés, chacun encore au
|
||||||
|
dépôt ; témoins sans écart ; historique d'Icinga, 1 171 lignes d'avant, 0 retirée ; pare-feu
|
||||||
|
13/13, 0 critique. Journal de montage : traces des minuteurs remises sur 12 VM, aucun conflit
|
||||||
|
de verrou apt ; applications de Nextcloud prises au site, aucun repli sur Internet,
|
||||||
|
signatures 2/2 ; recette Prometheus, un réessai puis 15 cibles. Demandé aux bases sur
|
||||||
|
07:50–08:52 : Prometheus, 15 cibles toutes les 10 min ; Loki, plus de 1 000 lignes par
|
||||||
|
tranche. Sondes de sauvegarde : OK, sauf `web-dorsal-01` (aucune application).
|
||||||
|
|
||||||
|
**Les deux locataires sont reconstruits de bout en bout sur `3afb756`** (entrées (119) et
|
||||||
|
(120)) : c'est le code à étiqueter, `v2026.10.09`.
|
||||||
|
|
||||||
|
## 2026-10-09 (119) — Technolibre reconstruite sur `3afb756`, d'une traite, sans l'app store
|
||||||
|
|
||||||
|
**94 preuves.** `make reconstruire-locataire TENANT=OPS-Technolibre` (43 min, journal
|
||||||
|
`OPS-Technolibre/logs/reconstruction-20261009-020435.log`), sans arrêt : le runner tire
|
||||||
|
`3afb756` ; 13/13 VM ; `valider` passé ; pare-feu 13/13, 0 critique.
|
||||||
|
|
||||||
|
### Les deux correctifs, vus dans le journal de montage
|
||||||
|
|
||||||
|
- **Minuteurs apt** : la trace héritée du gabarit est remise à l'heure sur les **12 VM** ;
|
||||||
|
**aucun** conflit de verrou apt.
|
||||||
|
- **Applications de Nextcloud** : `user_oidc` et `richdocuments` prises **au dépôt du site**,
|
||||||
|
repli sur Internet sauté, signature vérifiée (`integrity:check-app`), activées ; **aucune**
|
||||||
|
« prise sur Internet ».
|
||||||
|
- **Recette Prometheus** : un réessai, puis 15 cibles saines : l'attente de l'entrée (113) a
|
||||||
|
servi pour la première fois.
|
||||||
|
|
||||||
|
### La référence, après deux tentatives arrêtées
|
||||||
|
|
||||||
|
L'étape `sauvegarder` n'a rien déposé : la flotte rasée était celle de la tentative arrêtée
|
||||||
|
(socle interrompu avant `client_backup`), sans aucune unité de sauvegarde. C'est le bon
|
||||||
|
comportement : déposer depuis une flotte à moitié montée aurait étiqueté « avant-raser » un
|
||||||
|
état vide. La référence est restée celle de 21:16:53 (la flotte complète de 13:18), et 22:50
|
||||||
|
pour `infra-pki-01` (même AC restaurée).
|
||||||
|
|
||||||
|
**10 jeux** restaurés, chacun encore au dépôt ; témoins : aucun écart ; historique d'Icinga,
|
||||||
|
1 191 lignes d'avant, 0 retirée. Demandé aux bases sur 20:15–21:15 (avant la référence) :
|
||||||
|
Prometheus, 15 cibles toutes les 10 min ; Loki, environ 1 000 lignes par tranche. Sondes de
|
||||||
|
sauvegarde : OK, sauf `web-dorsal-01` (aucune application, rien à sauvegarder).
|
||||||
|
|
||||||
|
## 2026-10-08 (118) — Les clones rattrapaient un passage d'apt hérité du gabarit
|
||||||
|
|
||||||
|
**94 preuves.** La reconstruction de Technolibre sur `c7378ed` s'est arrêtée dès l'amorçage
|
||||||
|
du socle, sur `infra-pki-01` : `'apt-get autoremove' failed: E: Could not get lock
|
||||||
|
/var/cache/apt/archives/lock. It is held by process 13982 (apt-get)`.
|
||||||
|
|
||||||
|
### Ce qui s'est passé, à la seconde
|
||||||
|
|
||||||
|
- 22:54:30 : naissance de la VM ; `apt-daily.timer` et `apt-daily-upgrade.timer`, armés dans le
|
||||||
|
gabarit, démarrent.
|
||||||
|
- 22:58:30 : `common_packages` les réarme (décision du 2026-09-11 : les correctifs de sécurité
|
||||||
|
s'appliquent seuls, « armer un minuteur ne déclenche pas sa cible »).
|
||||||
|
- 22:59:20 : **`apt-daily-upgrade` part**, en pleine série d'opérations apt du rôle. Son
|
||||||
|
`apt-get` tient le verrou du **cache des archives**, que ni `lock_timeout` ni
|
||||||
|
`DPkg::Lock::Timeout` ne couvrent : `apt-get autoremove` échoue au lieu d'attendre.
|
||||||
|
|
||||||
|
**Pourquoi il est parti** : les deux minuteurs sont **persistants** (`Persistent=true`) et
|
||||||
|
gardent la date de leur dernier passage. La trace d'`apt-daily` sur ce clone datait du
|
||||||
|
**2026-06-21**, jour de fabrication du gabarit. Chaque VM clonée croit donc avoir manqué un
|
||||||
|
passage et le rattrape dans l'heure (délai aléatoire, `RandomizedDelaySec=60m`). Avec 13 VM
|
||||||
|
par reconstruction, c'est un tirage au sort depuis le 2026-09-11 ; les reconstructions
|
||||||
|
précédentes l'ont gagné. La prémisse « armer ne déclenche pas » est vraie pour un minuteur
|
||||||
|
neuf, fausse pour un clone.
|
||||||
|
|
||||||
|
### Fait
|
||||||
|
|
||||||
|
`common_packages/tasks/minuteurs.yml`, inclus avant toute opération apt (quand les mises à jour
|
||||||
|
restent armées) : une trace plus ancienne que la naissance de la machine (clé d'hôte SSH) est
|
||||||
|
remise à l'heure, et son minuteur relancé, ce qui recalcule le prochain passage sans
|
||||||
|
rattrapage ; puis on attend la fin d'un passage déjà en cours. La décision du 2026-09-11 tient :
|
||||||
|
les correctifs de sécurité restent automatiques.
|
||||||
|
|
||||||
|
### Éprouvé en vrai, sur `infra-pki-01` (clone neuf de Technolibre)
|
||||||
|
|
||||||
|
Premier passage : la trace d'`apt-daily` (2026-06-21) est remise à l'heure et le minuteur
|
||||||
|
relancé ; celle d'`apt-daily-upgrade` (22:59:20, postérieure à la naissance) n'est **pas**
|
||||||
|
touchée (témoin). Second passage : aucun changement. Prochains passages : demain 06:23 et
|
||||||
|
17:26 ; aucun passage automatique parti depuis la relance. Lint propre ; `make verifier`
|
||||||
|
conforme, **94/94**.
|
||||||
|
|
||||||
|
## 2026-10-08 (117) — Les applications de Nextcloud ne viennent plus de l'app store : le site les tient
|
||||||
|
|
||||||
|
**94 preuves.** La reconstruction de Technolibre sur `7a4c55b` (règle stricte, entrée (116)) s'est
|
||||||
|
arrêtée sur `collab-01` : « Could not download app user_oidc, it was not found on the
|
||||||
|
appstore », après deux minutes. Journal de Nextcloud, à la seconde : « Response from appstore
|
||||||
|
is invalid ». Mesuré depuis `collab-01` : le catalogue de l'app store (11,7 Mo) arrivait à
|
||||||
|
**77 ko/s**, un autre fournisseur à **18,8 Mo/s** ; Nextcloud abandonne et prend la réponse
|
||||||
|
tronquée pour invalide.
|
||||||
|
|
||||||
|
### Ce n'était pas une régression, c'était une dépendance jamais fermée
|
||||||
|
|
||||||
|
Mesurée le 2026-10-05 (reconstruction de Chezlepro observée à la frontière) et jamais écrite
|
||||||
|
ici : apt est local (6,2 Go servis par le cache du site, aucun `.deb` amont), mais trois
|
||||||
|
dépendances sortent encore sur Internet à chaque reconstruction — `pip` (PyPI) pour les
|
||||||
|
runners, `ansible-galaxy` sur un runner neuf, et `occ app:install user_oidc / richdocuments`
|
||||||
|
(app store de Nextcloud). C'est la troisième qui a mordu.
|
||||||
|
|
||||||
|
### Le mécanisme existait : le dépôt de binaires du site
|
||||||
|
|
||||||
|
Depuis le 2026-09-12, `site-cache-01` tient dans `/var/lib/setops/artefacts-directs`, et sert
|
||||||
|
sous `/setops-binaires` (port 3142, flux existant), les archives que les rôles téléchargent :
|
||||||
|
Forgejo, Keycloak, Nextcloud, oauth2-proxy — déclarées une seule fois, dans le rôle qui les
|
||||||
|
consomme. Les applications de Nextcloud n'y avaient jamais été ajoutées. (Une forge-registre
|
||||||
|
avait d'abord été proposée : le mécanisme existant la rend inutile.)
|
||||||
|
|
||||||
|
**Essai d'abord** : versions relevées sur Chezlepro (`user_oidc` 8.11.0, `richdocuments` 11.1.2,
|
||||||
|
sur Nextcloud 34.0.4) ; archives de publication GitHub en 0,7 et 1,2 s ; `occ
|
||||||
|
integrity:check-app --path` valide leur code hors ligne contre l'autorité de signature de
|
||||||
|
Nextcloud ; témoin : une ligne ajoutée à un fichier donne `INVALID_HASH`.
|
||||||
|
|
||||||
|
**Fait** :
|
||||||
|
- `serveur_nextcloud_apps` : chaque application, version, `nom`, `url`, SHA-256.
|
||||||
|
- `serveur_artefacts` : le site tient ces archives (il parcourt la liste, ne la recopie pas).
|
||||||
|
- `serveur_nextcloud/tasks/app_tierce.yml` : dépôt du site d'abord, amont en repli **dit à
|
||||||
|
voix haute** ; somme vérifiée dans les deux cas ; dépôt dans `apps/` ; `occ
|
||||||
|
integrity:check-app` ; activation. Une version installée qui diffère de l'épinglée
|
||||||
|
**arrête** (une mise à niveau se décide). Plus aucun `app:install`.
|
||||||
|
- **P70** comprend désormais les listes d'artefacts : un `dest` modèle attend chacun de ses
|
||||||
|
noms, tenus seulement si le site parcourt la liste.
|
||||||
|
|
||||||
|
**Éprouvé** : `test_apps_nextcloud.py` (dans `make test`) ; témoin sur l'ancien `oidc.yml`, deux
|
||||||
|
échecs. P70 : témoin, le site ne parcourant plus la liste, échec. Lint propre. `make verifier`
|
||||||
|
conforme, **94/94**.
|
||||||
|
|
||||||
|
**Reste** : PyPI et Galaxy, même chemin. Puis la preuve : reconstruire avec l'amont du cache du
|
||||||
|
site coupé (`serveur_artefacts_hors_ligne`), et constater que rien ne sort.
|
||||||
|
|
||||||
|
## 2026-10-08 (116) — Chezlepro reconstruite sur `7a4c55b`, d'une traite
|
||||||
|
|
||||||
|
**94 preuves.** `make reconstruire-locataire TENANT=OPS-Chezlepro` (46 min, journal
|
||||||
|
`OPS-Chezlepro/logs/reconstruction-20261008-182930.log`), sans reprise : le runner tire
|
||||||
|
`7a4c55b` ; 13/13 VM ; `valider` du premier coup (15 cibles) ; pare-feu 13/13, 0 critique.
|
||||||
|
|
||||||
|
**9 jeux** restaurés depuis l'instantané `avant-raser` de 18:29, tous encore au dépôt.
|
||||||
|
Témoins : aucun écart ; Nextcloud, annuaire, AC d'Icinga, web dorsal identiques ; Loki, aucun
|
||||||
|
morceau retiré ; historique d'Icinga, 777 lignes d'avant, 0 retirée. Demandé aux bases, sur
|
||||||
|
17:30–18:29 : Prometheus, 15 cibles toutes les 10 min ; Loki, 1 000 à 2 000 lignes par tranche
|
||||||
|
de 10 min jusqu'au rasage. Sondes de sauvegarde : OK, sauf `web-dorsal-01` (aucune
|
||||||
|
application déployée, rien à sauvegarder).
|
||||||
|
|
||||||
|
**La recette corrigée n'a pas eu à attendre** : le premier dépôt d'`obs-01` a relancé
|
||||||
|
Prometheus à 19:13:02, la recette l'a interrogé à 19:13:29 (27 s, plus qu'un intervalle de
|
||||||
|
relevé). Son attente reste prête pour un écart plus court, comme les 18 s de Technolibre.
|
||||||
|
|
||||||
|
**Release** : l'exploitant applique la règle stricte. Technolibre, reconstruite sur `2dce6ea`
|
||||||
|
(recette et bilan rejoués sur `9ca9a5f`), est reconstruite à nouveau sur `7a4c55b`, pour que
|
||||||
|
les deux locataires portent le même commit avant d'étiqueter.
|
||||||
|
|
||||||
|
## 2026-10-08 (115) — La sonde du dépôt jugeait le premier groupe d'instantanés, pas le plus récent
|
||||||
|
|
||||||
|
**94 preuves.** Question de l'exploitant : « on a aussi les sondes pour vérifier que les
|
||||||
|
sauvegardes sont OK ? » Oui : le contrôle du dépôt (toutes les 4 h : existe, récent, non vide,
|
||||||
|
lu dans le dépôt distant) et le contrôle de restauration (chaque semaine : relecture d'une
|
||||||
|
part des données, vraie restauration vérifiée), poussés à Icinga, dont le silence alerte.
|
||||||
|
Leur état, lu dans Icinga DB des trois écosystèmes : tout OK chez les locataires (dont
|
||||||
|
`obs-01`), sauf `web-dorsal-01` (rien à sauvegarder, aucune application déployée). Au site :
|
||||||
|
**`site-mon-01` « EN RETARD : dernier instantané il y a 26 h »**, alors que la sauvegarde de
|
||||||
|
la nuit était passée à 02:32.
|
||||||
|
|
||||||
|
### Cause : le défaut venait de mon chantier de la veille
|
||||||
|
|
||||||
|
`restic snapshots --latest 1` rend le dernier instantané de **chaque groupe** (machine ET liste
|
||||||
|
de chemins). Ajouter `prometheus`, `loki` et `icinga` à `site-mon-01` a changé sa liste : deux
|
||||||
|
groupes. La sonde gardait `d[0]`, l'ancien, figé au 2026-10-07 02:41. Il n'aurait fait que
|
||||||
|
vieillir : **« PÉRIMÉ » (critique) à 50 h**, le 2026-10-09 au matin, sur une sauvegarde saine,
|
||||||
|
et de même sur toute machine qui reçoit un nouveau jeu. Même motif dans
|
||||||
|
`serveur_backup/verifier-sauvegardes.sh.j2`.
|
||||||
|
|
||||||
|
**Fait** : les deux sondes retiennent le plus récent de tous les instantanés rendus.
|
||||||
|
**Éprouvé** : `test_verifier_depot.py` (dans `make test`), la sonde rendue depuis son gabarit,
|
||||||
|
`restic` et `curl` doublés : deux groupes, l'ancien (30 h) en premier, verdict OK (1 h) ; un
|
||||||
|
seul groupe vieux de 30 h, toujours EN RETARD. Témoin : l'ancien gabarit rend « EN RETARD :
|
||||||
|
30 h », le symptôme du site. `make verifier` conforme, **94/94**.
|
||||||
|
|
||||||
|
**Déployé** (simulé d'abord, 0 échec) : `client_backup` chez Technolibre et au site,
|
||||||
|
`serveur_backup` au site. Les deux vérifications relancées aussitôt : les six services
|
||||||
|
« sauvegarde » du site sont OK, dont `site-mon-01` (« il y a 15 h », 6 696 fichiers, 1,69 Go),
|
||||||
|
vu par lui-même et par `site-backup-01`. Chezlepro reçoit la sonde par sa reconstruction.
|
||||||
|
|
||||||
|
**Une fausse alerte, de ma part** : la simulation de `serveur_backup` changeait
|
||||||
|
`/etc/setops/icinga-api.pass` sur `site-backup-01`. Comparée sans l'afficher, la valeur a
|
||||||
|
d'abord paru différente de la voûte — mon expression de retrait des blancs était mal
|
||||||
|
échappée et ne retirait rien. Refaite proprement : Icinga, la voûte et les fichiers des deux
|
||||||
|
machines portent le même mot de passe ; seul un blanc de fin différait, que `$(cat …)` ignore.
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-10-08 (114) — Les métriques et les journaux traversent la reconstruction
|
||||||
|
|
||||||
|
**94 preuves.** Technolibre rasée et reconstruite sur `2dce6ea` (arrêt à `valider`, entrée
|
||||||
|
(113) ; recette repassée sur `9ca9a5f`, reprise `DEPUIS=parefeu`). Journal :
|
||||||
|
`OPS-Technolibre/logs/reconstruction-20261008-131846.log`, puis `…-162819.log`.
|
||||||
|
|
||||||
|
### La restauration de Prometheus et de Loki, vécue
|
||||||
|
|
||||||
|
**9 jeux** restaurés depuis l'instantané `avant-raser` de 13:18:50, chacun encore au dépôt :
|
||||||
|
dont `prometheus` et `loki` (obs-01) et `icinga` (mon-01). Pare-feu Proxmox 13/13, 0 critique.
|
||||||
|
Témoins : **aucun écart**. Prometheus : un bloc vivant couvre le dernier bloc d'avant ; AC
|
||||||
|
d'Icinga identique ; historique d'Icinga, 803 lignes d'avant, 0 retirée.
|
||||||
|
|
||||||
|
**Demandé aux bases elles-mêmes**, sur 12:00–13:18 (avant le rasage) :
|
||||||
|
- Prometheus : un point toutes les 10 minutes, 15 cibles à chacun ;
|
||||||
|
- Loki : neuf tranches de 10 minutes, environ 1 000 lignes chacune, jusqu'à 13:18.
|
||||||
|
|
||||||
|
### Le témoin de Loki comptait l'index parmi les morceaux
|
||||||
|
|
||||||
|
Il disait « 3 morceaux d'avant retirés (rétention ?) ». La base a moins de deux jours, ce ne
|
||||||
|
pouvait pas être la rétention. Liste récursive de l'instantané contre la base vivante (un
|
||||||
|
premier `restic ls` sans `--recursive` ne listait que le premier niveau) : les trois sont des
|
||||||
|
fichiers d'**index** (`chunks/index/index_20734/…tsdb.gz`, dont une sortie du compacteur), que
|
||||||
|
le compacteur fusionne et remplace. Aucun journal n'était perdu.
|
||||||
|
|
||||||
|
**Fait** : le témoin de Loki ne compare que les morceaux, hors `chunks/index/` (que la copie à
|
||||||
|
froid traite déjà à part). Test : un index compacté ne fait ni écart ni retrait ; témoin :
|
||||||
|
avec l'ancienne règle, il échoue. `make verifier` conforme, **94/94**.
|
||||||
|
|
||||||
|
## 2026-10-08 (113) — La recette jugeait Prometheus 18 secondes après sa relance
|
||||||
|
|
||||||
|
**94 preuves.** La reconstruction de Technolibre (`2dce6ea`) s'est arrêtée à `valider` :
|
||||||
|
« cibles DOWN : `10.23.18.11:9187`, `localhost:9090` », sur `obs-01`.
|
||||||
|
|
||||||
|
### Ce qui s'est passé
|
||||||
|
|
||||||
|
- 13:42:30 : `serveur_prometheus` arrête Prometheus, remet la base de l'instantané d'avant
|
||||||
|
rasage, relance. Rejeu du WAL restauré : 0,46 s ; prêt dans la seconde. **La restauration a
|
||||||
|
fonctionné.**
|
||||||
|
- 13:59:53 : le premier dépôt d'`obs-01` (gestionnaire « Premier rapport à Icinga » de
|
||||||
|
`client_backup`, en fin de déploiement) fait sa copie à froid. Prometheus est arrêté 388 ms,
|
||||||
|
puis relancé. C'est nouveau : hier, `obs-01` n'était pas sauvegardé.
|
||||||
|
- 14:00:11 : la recette interroge les cibles, **18 s après la relance** ; deux n'ont pas encore
|
||||||
|
de relevé réussi.
|
||||||
|
- 16:17 : interrogé à nouveau, aucune cible en panne.
|
||||||
|
|
||||||
|
### Fait
|
||||||
|
|
||||||
|
La recette Prometheus redemande les cibles **jusqu'à ce que toutes soient relevées, une minute
|
||||||
|
au plus**, puis juge. Une vraie panne échoue toujours, une minute plus tard. La condition est
|
||||||
|
éprouvée sur des réponses fabriquées (toutes saines : arrêt ; une inconnue : attente ; une en
|
||||||
|
panne après six tentatives : jugement ; API muette : pas d'attente vaine). Syntaxe et lint
|
||||||
|
propres.
|
||||||
|
|
||||||
|
**Pas éprouvé en vrai** : la reprise ne reproduira pas la relance (le premier rapport
|
||||||
|
d'`obs-01` est fait, son marqueur posé). La prochaine reconstruction de Chezlepro, oui.
|
||||||
|
|
||||||
|
## 2026-10-08 (112) — Keycloak ne régénère pas ses clés à la reconstruction ; ses sessions hors ligne perdues sont celles d'admin-cli
|
||||||
|
|
||||||
|
**94 preuves.** Deux signaux des témoins, à chaque reconstruction : `component_config`
|
||||||
|
« 23 retirée(s), 23 nouvelle(s) » sur 139, et les sessions hors ligne perdues. Hypothèse :
|
||||||
|
les clés de signature du royaume régénérées par le rôle, ce qui invaliderait tous les jetons
|
||||||
|
en circulation.
|
||||||
|
|
||||||
|
### Mesuré, et l'hypothèse tombe
|
||||||
|
|
||||||
|
Chezlepro, instantané `avant-raser` (`54444365`) contre base vivante, composant par
|
||||||
|
composant et valeur par valeur : **38 composants des deux côtés, aucun perdu, aucun nouveau,
|
||||||
|
aucune valeur changée**, clés comprises. Les « 23 renouvelées » étaient un faux signal du
|
||||||
|
comparateur : il juge une table par sa première colonne, et celle de `component_config` est
|
||||||
|
un identifiant de ligne, que Keycloak réécrit à contenu égal. Listé sans écart, donc sans
|
||||||
|
conséquence, mais trompeur.
|
||||||
|
|
||||||
|
### Les sessions hors ligne
|
||||||
|
|
||||||
|
Toutes celles de la base vivante appartiennent à **`admin-cli`**, pour un seul compte :
|
||||||
|
l'outillage des déploiements, qui les recrée. Aucune application des utilisateurs n'en
|
||||||
|
détient. Les sessions de navigateur, elles, disparaissent avec les VM : se reconnecter une
|
||||||
|
fois après une reconstruction en fait partie.
|
||||||
|
|
||||||
|
**Pas un défaut aujourd'hui.** Il le deviendrait le jour où une application gardera des
|
||||||
|
jetons hors ligne pour le compte d'une personne (client de synchronisation Nextcloud de
|
||||||
|
bureau ou mobile, par exemple) : chaque reconstruction obligerait à se réauthentifier. La
|
||||||
|
raison pour laquelle les sessions d'`admin-cli` disparaissent n'est pas établie.
|
||||||
|
|
||||||
|
## 2026-10-08 (111) — Le catalogue des capteurs au site, et une mise à jour que la simulation ne montrait pas
|
||||||
|
|
||||||
|
**94 preuves.** Le catalogue des capteurs d'Icinga (accents, consignes « action », 2026-10-04)
|
||||||
|
n'avait jamais été déployé au site. `make site-appliquer GROUPE=serveur_icinga`, simulé
|
||||||
|
d'abord : 0 échec ; le catalogue (`/etc/icinga2/setops-materiel.json`) porte ses 14 consignes,
|
||||||
|
accents compris ; configuration validée ; jeu `icinga` acté.
|
||||||
|
|
||||||
|
### Ce que la simulation ne pouvait pas montrer
|
||||||
|
|
||||||
|
La simulation montrait trois fichiers d'épinglage neufs (`icinga2`, `icingadb`,
|
||||||
|
`icingadb-redis`) : le site n'avait jamais reçu l'épinglage des paquets tiers. Comparés aux
|
||||||
|
versions installées, deux étaient égaux ; `icingadb-redis` était installé en **8.2.9**,
|
||||||
|
épinglé en **8.2.10** (la version déclarée, celle des locataires). J'ai dit à l'exploitant que
|
||||||
|
ce déploiement ne l'installerait pas. **C'était faux** : `paquets_tiers` installe depuis son
|
||||||
|
cache les paquets qu'il vient de déposer. En simulation, rien n'est déposé, donc rien ne
|
||||||
|
s'installe, et la tâche ne paraît pas. Au vrai passage : `icingadb-redis` 8.2.9 -> 8.2.10 à
|
||||||
|
00:25:24, Redis redémarré à 00:25:26.
|
||||||
|
|
||||||
|
**Vérifié ensuite** : Icinga 2 s'est reconnecté à Redis à 00:25:26 ; Icinga DB a repris sa
|
||||||
|
confirmation d'instance active à 00:27:52, à son rythme de cinq minutes. Le dernier état écrit
|
||||||
|
en base datait de 00:10:35, ce qui a d'abord inquiété : Icinga DB n'écrit un état que
|
||||||
|
lorsqu'il change (une écriture en 75 minutes), son âge ne mesure donc rien. Services non OK
|
||||||
|
dans l'API : quatre, tous connus et sans rapport (matériel de `gandalf`, correctifs en
|
||||||
|
attente).
|
||||||
|
|
||||||
|
**Retenu** : une simulation de `paquets_tiers` ne montre pas les installations. Comparer les
|
||||||
|
versions épinglées aux versions installées, et le dire comme une mise à jour.
|
||||||
|
|
||||||
|
### La VM 99998
|
||||||
|
|
||||||
|
Supprimée du cluster par l'exploitant (suite de l'entrée (106)).
|
||||||
|
|
||||||
|
## 2026-10-07 (110) — Les 13 s de Loki n'étaient pas la copie : c'était son arrêt, qui attend un envoi d'Alloy
|
||||||
|
|
||||||
|
**94 preuves.** Correction de l'entrée (109). Déployée partout (`client_backup` chez les deux
|
||||||
|
locataires et au site, simulé d'abord, 0 échec), la liaison des morceaux a été mesurée sur un
|
||||||
|
dépôt ordinaire lancé sur `site-mon-01` : Loki est encore resté **10,8 s** à l'arrêt.
|
||||||
|
|
||||||
|
### Ce que j'avais mesuré, et ce que je n'avais pas mesuré
|
||||||
|
|
||||||
|
J'avais chronométré la COPIE à part (1,5 s à chaud, 13 s pendant la sauvegarde), jamais
|
||||||
|
l'ARRÊT. Le journal de systemd tranche : « Stopping » à 23:47:25.65, « Stopped » à
|
||||||
|
23:47:36.40, soit **10,75 s pour s'arrêter**. La copie, liée, prend environ 60 ms : « Started »
|
||||||
|
à 23:47:36.46. Le morceau vérifié a bien deux liens (la base et la copie).
|
||||||
|
|
||||||
|
**Pourquoi 10 s** : le journal de Loki montre `POST /loki/api/v1/push (500) 10.000656983s`.
|
||||||
|
À l'arrêt, Loki attend la fin des requêtes en cours ; un envoi d'un agent Alloy n'a abouti
|
||||||
|
qu'à l'expiration du délai d'Alloy (10 s par défaut), en 500. Alloy réessaie un 500.
|
||||||
|
|
||||||
|
**Rien de perdu**, mesuré : aucune tranche de 30 s vide dans Loki autour de l'arrêt (3 058 et
|
||||||
|
3 984 lignes à 23:47, avec le rattrapage d'Alloy) ; Prometheus, un seul relevé manqué (30 s).
|
||||||
|
|
||||||
|
**Laissé tel quel** : raccourcir cet arrêt voudrait dire tuer Loki avant la fin de son arrêt
|
||||||
|
propre, ou baisser le délai d'Alloy en temps normal. Deux risques pour gagner dix secondes
|
||||||
|
par nuit, sans aucune perte à rattraper. La liaison reste : elle retire 1,5 à 2 s de copie et
|
||||||
|
la place en double.
|
||||||
|
|
||||||
|
## 2026-10-07 (109) — Les morceaux de Loki sont liés, pas copiés ; sa base de suppressions, si
|
||||||
|
|
||||||
|
**94 preuves.** Au site, la copie à froid arrêtait Loki **13 s**, pour copier 6 550 petits
|
||||||
|
fichiers. Ses morceaux de journal sont adressés par leur contenu : un nom décodé donne
|
||||||
|
`1a0d123a4e8:1a0d1918235:6b5feaac` (début, fin, somme de contrôle). Ils ne bougent donc
|
||||||
|
jamais, et les lier prend **40 ms** (mesuré sur `site-mon-01`).
|
||||||
|
|
||||||
|
### Le fichier qui bougeait parmi les morceaux
|
||||||
|
|
||||||
|
Un contrôle d'immuabilité, refait avec `stat` (le `find` de la machine ne connaît pas `%W` :
|
||||||
|
le premier passage ne mesurait rien), a trouvé **un fichier sur 6 550** modifié après sa
|
||||||
|
naissance : `chunks/index/delete_requests/delete_requests.gz`, né le 2026-09-12 et réécrit le
|
||||||
|
2026-10-07. Ce n'est pas un morceau : c'est la base des demandes de suppression du compacteur,
|
||||||
|
rangée dans le même stockage objet. Lier tout `chunks/` aurait laissé la sauvegarde changer
|
||||||
|
avec elle.
|
||||||
|
|
||||||
|
**Fait** : `setops-copie-a-froid` accepte `--lier REL` (lier tout un sous-dossier) et
|
||||||
|
`--copier REL` (y copier vraiment ce qui bouge). Loki : `--lier chunks --copier chunks/index`.
|
||||||
|
|
||||||
|
**Éprouvé** : `test_restauration.py` ; morceau lié (même inode), `chunks/index` copié (autre
|
||||||
|
inode), une réécriture après la copie ne touche pas la sauvegarde. Témoin : sans `--copier`,
|
||||||
|
les deux derniers échouent. `shellcheck` propre. `make verifier` conforme, **94/94**.
|
||||||
|
|
||||||
|
**Pas encore déployé** : la durée réelle se mesurera au prochain dépôt.
|
||||||
|
|
||||||
|
## 2026-10-07 (108) — Le site recevait encore la sauvegarde d'avant le 2026-09-30
|
||||||
|
|
||||||
|
**94 preuves.** Déployer la copie à froid de Prometheus et Loki au site (`site-mon-01`) a
|
||||||
|
révélé que les trois machines sauvegardées du site n'avaient **pas `setops-restaurer`** :
|
||||||
|
leur `client_backup` datait d'avant la restauration (2026-09-30). Ni garde, ni étiquette
|
||||||
|
`avant-raser`, ni témoins. Ce qui était cohérent avec un site jamais reconstruit, mais pas
|
||||||
|
avec un site qui doit pouvoir l'être.
|
||||||
|
|
||||||
|
### Simulé, puis déployé (confirmé par l'exploitant)
|
||||||
|
|
||||||
|
- **La garde ne bloquerait rien** : machines nées le 2026-09-12 vers 20:20, plus anciens
|
||||||
|
instantanés le 2026-09-13 à 02:3x, donc aucune incarnation précédente. Chaque jeu est acté
|
||||||
|
« neuf ».
|
||||||
|
- `make site-appliquer GROUPE=client_backup` (`--check --diff` d'abord) : 0 échec sur
|
||||||
|
`site-mon-01`, `site-forge-01`, `site-pki-01` ; premier rapport complet. Jeux :
|
||||||
|
`postgresql`, `prometheus`, `loki`, `icinga` (mon) ; `forgejo` (forge, déjà « en place »
|
||||||
|
depuis le 2026-10-04) ; `step_ca` (pki).
|
||||||
|
- `serveur_prometheus`, `serveur_loki`, `serveur_icinga` **non redéployés** au site : leur
|
||||||
|
restauration ne sert qu'à une reconstruction, et `serveur_icinga` aurait embarqué un retard
|
||||||
|
sans rapport (catalogue des capteurs du 2026-10-04, jamais posé au site).
|
||||||
|
|
||||||
|
### Mesuré
|
||||||
|
|
||||||
|
| `site-mon-01` | |
|
||||||
|
|---|---|
|
||||||
|
| Prometheus (2,9 Go) arrêté | 523 ms ; écart maximal entre deux échantillons : 30 s (normal 15 s) |
|
||||||
|
| Loki (285 Mo, 6 512 fichiers) arrêté | **13 074 ms** : la copie de petits fichiers, pas la lecture (2 s) |
|
||||||
|
| Premier dépôt | 3,16 Gio (1,57 Gio stockés) |
|
||||||
|
|
||||||
|
Pendant les 13 s de Loki : 0 erreur, 0 abandon chez les 12 agents Alloy du site, et **aucune
|
||||||
|
tranche de 30 s vide** dans Loki (`count_over_time`, de 23:11 à 23:16) ; celle de l'arrêt
|
||||||
|
compte 2 538 lignes. Les journaux émis pendant l'arrêt sont arrivés, avec leur heure.
|
||||||
|
|
||||||
|
**À considérer** : les morceaux de Loki sont immuables, comme les blocs de Prometheus. Les
|
||||||
|
lier au lieu de les copier ramènerait son arrêt sous la seconde.
|
||||||
|
|
||||||
|
**Hors périmètre, remarqué** : le catalogue des capteurs d'Icinga (accents, champs
|
||||||
|
`action`, 2026-10-04) n'a jamais été déployé au site.
|
||||||
|
|
||||||
|
## 2026-10-07 (107) — Les métriques et les journaux repartaient de zéro : copie à froid de Prometheus et Loki
|
||||||
|
|
||||||
|
**94 preuves.** Prometheus et Loki n'étaient pas sauvegardés : chaque reconstruction
|
||||||
|
effaçait les métriques (15 jours) et les journaux (31 jours). Décision de l'exploitant : les
|
||||||
|
sauvegarder, avec un arrêt bref plutôt qu'une copie à chaud (« on ne prend pas de risque »).
|
||||||
|
|
||||||
|
### Mesuré avant d'écrire
|
||||||
|
|
||||||
|
Au site (`site-mon-01`, jamais reconstruit) : Prometheus **2,9 Go** (22 blocs), Loki **285 Mo**
|
||||||
|
(6 512 fichiers) ; chez les locataires, reconstruits le jour même, 41 à 98 Mo et 10 Mo. Seuls
|
||||||
|
le WAL de Prometheus (178 Mo) et `chunks_head` (21 Mo) bougent ; le reste est fait de blocs
|
||||||
|
immuables. Lecture réelle : 4,2 s pour tout Prometheus, 0,3 s pour son WAL, 2 s pour Loki. Un
|
||||||
|
premier chronométrage disait 6 ms pour 2,9 Go : GNU tar ne lit rien quand il écrit vers
|
||||||
|
`/dev/null`. C'est l'instrument qui mentait, pas le disque.
|
||||||
|
|
||||||
|
### Fait
|
||||||
|
|
||||||
|
- **`setops-copie-a-froid`** (client_backup) : arrête le service, copie, le relance **quoi
|
||||||
|
qu'il arrive** et seulement s'il tournait ; la copie précédente n'est remplacée qu'une fois
|
||||||
|
la nouvelle complète. Les blocs de Prometheus (dossiers ULID) sont liés, le WAL et
|
||||||
|
`chunks_head` copiés. Il dit la durée d'arrêt.
|
||||||
|
- **Jeux `prometheus` et `loki`** au catalogue : la copie à froid va dans
|
||||||
|
`/var/backups/setops/<jeu>`, et restic l'emporte. `vivant` dit où vit la base.
|
||||||
|
- **Restauration** dans `serveur_prometheus` (juste après le paquet, base sans bloc) et
|
||||||
|
`serveur_loki` (avant le démarrage, base sans morceau de journal) : service arrêté,
|
||||||
|
`setops-restaurer fichiers --depuis <copie> <base vivante>`, acté.
|
||||||
|
- **Témoins** : Prometheus, un bloc vivant doit couvrir le début du dernier bloc d'avant
|
||||||
|
(la rétention ne l'a pas retiré, une base non restaurée ne peut pas le couvrir) ; sans
|
||||||
|
bloc, c'est le WAL qui sert de repère. Loki : aucun morceau d'avant subsistant = écart ; une
|
||||||
|
perte partielle (rétention) est listée.
|
||||||
|
- **`obs-01` entre dans `client_backup`** chez les deux locataires (P36 l'exigeait :
|
||||||
|
« obs-01 détient serveur_loki, serveur_prometheus sans sauvegarde »). Plan, inventaire
|
||||||
|
régénéré (`instancier-appliquer FORCE=1`, confirmé par l'exploitant), flux (`mon-01` accepte
|
||||||
|
désormais les rapports d'`obs-01` sur 5665), faces republiées.
|
||||||
|
|
||||||
|
### Éprouvé
|
||||||
|
|
||||||
|
`test_temoins.py` : blocs restaurés puis fusionnés, base née après la reconstruction (écart),
|
||||||
|
rétention, WAL seul, Loki restauré, partiel, absent (écart). `test_restauration.py` :
|
||||||
|
`--depuis`, et la copie à froid avec un faux `systemctl` (arrêt, copie, relance ; bloc lié,
|
||||||
|
WAL copié ; copie ratée : service **relancé quand même**, copie précédente intacte ; service
|
||||||
|
inactif laissé tel quel). Témoin : sans la relance de secours, le test échoue. `shellcheck`
|
||||||
|
propre. `make verifier` conforme, **94/94**.
|
||||||
|
|
||||||
|
### Déployé, mesuré (actions confirmées par l'exploitant)
|
||||||
|
|
||||||
|
Ajouter un détenteur d'état ouvre des flux, et l'ordre compte :
|
||||||
|
- **Frontière** : rien à faire (`frontiere-plan` : 0 à créer, 0 à retirer).
|
||||||
|
- **Pare-feu Proxmox** (`proxmox-fw-appliquer`, depuis le runner du site) : `t17-cli-backup`
|
||||||
|
et `t23-cli-backup` gagnent chacun `obs-01` (10 -> 11) ; relu, rien à faire ensuite. Le
|
||||||
|
devis exige encore un locataire monté sur le runner (`Inventaire introuvable sous
|
||||||
|
…/instance`) : lancé avec `SETOPS_INSTANCE=/opt/setops/OPS-Technolibre`. C'est un reste de
|
||||||
|
l'étape 3 des contextes.
|
||||||
|
- **nftables de `mon-01`** (`serveur_durci --limit mon-01`, simulé d'abord) : seule la règle
|
||||||
|
5665 gagne `obs-01` ; relu dans le jeu de règles chargé, sur les deux locataires.
|
||||||
|
- **Puis** `serveur_icinga`, `client_backup`, `serveur_prometheus`, `serveur_loki` : 0 échec ;
|
||||||
|
premier rapport complet d'`obs-01` ; jeux `prometheus` et `loki` actés « neufs » par le
|
||||||
|
premier dépôt (aucun instantané antérieur), donc rien n'a été écrasé.
|
||||||
|
|
||||||
|
**Le trou de collecte, mesuré** (écart maximal entre deux échantillons de `up`, sur 15
|
||||||
|
cibles, autour de l'arrêt) :
|
||||||
|
|
||||||
|
| | Prometheus arrêté | Loki arrêté | écart maximal |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Chezlepro | 151 ms | 614 ms | 30 s (intervalle normal : 15 s) |
|
||||||
|
| Technolibre | 85 ms | 582 ms | 30 s |
|
||||||
|
|
||||||
|
Un seul relevé manqué par cible. Premier dépôt : 146 Mo (43 Mo stockés) et 124 Mo (40 Mo).
|
||||||
|
|
||||||
|
**Pas encore éprouvé** : la restauration elle-même (prochaine reconstruction). Le site
|
||||||
|
(`site-mon-01`, 2,9 Go, WAL de 178 Mo) n'est pas déployé ; son rejeu de WAL sera plus long :
|
||||||
|
simuler avant.
|
||||||
|
|
||||||
|
**Hors périmètre, remarqué** : `make flux` a réécrit les 9 `connectivite.json` du site (+188
|
||||||
|
lignes, `sorties_externes`). Le générateur les produit depuis `d6591cb` (2026-10-05), mais les
|
||||||
|
fichiers du site datent du 2026-10-01. Changement annulé ici, à régénérer à part.
|
||||||
|
|
||||||
|
## 2026-10-07 (106) — Le gabarit 99998 sort des déclarations
|
||||||
|
|
||||||
|
**94 preuves.** L'exploitant a déclaré obsolète l'ancien gabarit, `99998`. Le neuf (`9006`,
|
||||||
|
sur `CephNVMe`) a cloné les cinq reconstructions complètes du 2026-10-07.
|
||||||
|
|
||||||
|
### Deux déclarations le nommaient encore
|
||||||
|
|
||||||
|
- **`OPS-Technolibre/inventories/principal/group_vars/proxmox.yml`** :
|
||||||
|
`proxmox_clone_vmid_modele: 99998` et `proxmox_clone_source_nom: modeleSetOPS`. Plus rien
|
||||||
|
ne les clonait (`cloner-vm` passe le gabarit du site en `-e`, entrée (102)). Mises en
|
||||||
|
commentaire, comme chez Chezlepro depuis le 2026-09-01 ; la face de Technolibre
|
||||||
|
republiée (`make face-reseau-publier`) perd exactement ces deux lignes.
|
||||||
|
- **`SITE-Chezlepro/plan/10-intrants.yml`** : `gabarit.precedent: 99998`, « gardé le temps
|
||||||
|
de prouver le neuf sur une reconstruction complète ». Aucun script ne le lisait. Retiré.
|
||||||
|
Les fiches que le site dépose chez ses locataires (`make fiches-site-deposer`) ne changent
|
||||||
|
que par l'empreinte de ce fichier ; leur contenu est identique.
|
||||||
|
|
||||||
|
**Validation** : `make verifier` conforme, **94/94**. Un premier passage a échoué, et c'était
|
||||||
|
juste : P91 refusait les deux `fiche-site.yml`, périmées par l'empreinte de leur source.
|
||||||
|
|
||||||
|
**Reste** : la VM `99998` est toujours sur le cluster. La supprimer touche un hyperviseur
|
||||||
|
(R4) : décision à part.
|
||||||
|
|
||||||
|
## 2026-10-07 (105) — Les releases et le wiki n'allaient que là où on les poussait à la main
|
||||||
|
|
||||||
|
**94 preuves.** La forge du site fait autorité (D-81), mais deux choses n'y arrivaient
|
||||||
|
jamais d'elles-mêmes : les étiquettes de release, et le wiki.
|
||||||
|
|
||||||
|
### Les releases manquaient à la forge du site
|
||||||
|
|
||||||
|
`v2026.10.07` poussée sur eregion, la question suivante a été : et la forge du site ? Mesure
|
||||||
|
(`git ls-remote --tags`) : elle portait `v2026.10.04`, pas `v2026.08.21` ni `v2026.10.07`. Le
|
||||||
|
colis de `make publier` (`genome_colis.py`) ne transporte que `refs/heads/<branche>`, et
|
||||||
|
`publier.py` ne poussait que la branche vers eregion. `v2026.10.04` y avait été portée par un
|
||||||
|
geste à part, que rien ne gardait. Les deux étiquettes manquantes ont été poussées par
|
||||||
|
`origin` (rebond SSH du poste), et relues identiques des deux côtés.
|
||||||
|
|
||||||
|
**Fait** :
|
||||||
|
- `genome_colis.py` : le colis emporte toutes les étiquettes (`bundle create … --tags`) ; le
|
||||||
|
runner les récupère et les pousse avec la branche, **sans forcer** : une étiquette déjà
|
||||||
|
présente sous un autre objet est refusée, des deux côtés. Une release ne bouge pas.
|
||||||
|
- `genome_pousser.yml` : la forge est relue par son API (`git/refs/tags`), et doit porter
|
||||||
|
toutes les étiquettes du poste.
|
||||||
|
- `publier.py` pousse aussi les étiquettes vers eregion.
|
||||||
|
|
||||||
|
**Option écartée** : faire d'eregion un miroir poussé par la forge du site. Mesuré : eregion
|
||||||
|
porte deux branches de contribution non fusionnées (`essai_integration`, 4 commits ;
|
||||||
|
`fix_client_pki`, demande de fusion n° 5 ouverte), qu'un miroir effacerait. L'exploitant
|
||||||
|
garde les deux forges poussées depuis le poste, par prudence envers les contributions.
|
||||||
|
|
||||||
|
**Éprouvé** : `test_genome_colis.py` (dans `make test`), sur de vrais dépôts git jetables :
|
||||||
|
sans étiquette, la tête passe ; une release annotée arrive sur la forge, même objet ; une
|
||||||
|
étiquette déplacée est refusée, la forge garde l'ancienne. Témoin : sur l'ancien
|
||||||
|
`genome_colis.py`, le test échoue dès « la release voyage jusqu'à la forge ». `make
|
||||||
|
verifier` conforme, **94/94**.
|
||||||
|
|
||||||
|
**Pas encore éprouvé** : la relecture par l'API de la forge (`git/refs/tags`). Le premier
|
||||||
|
`make publier` qui suivra le dira : « N etiquette(s) du poste, toutes sur la forge ».
|
||||||
|
|
||||||
|
### Le wiki de la forge du site était vide
|
||||||
|
|
||||||
|
`make wiki-publier` publie vers UNE forge (`WIKI_REMOTE`) ; son témoin
|
||||||
|
(`docs/audit/wiki-publie.yml`) montrait la dernière publication, le 2026-09-29, vers eregion
|
||||||
|
seulement. Rien n'avait jamais publié vers la forge du site. Sa branche de wiki demandée à
|
||||||
|
l'API de Forgejo (`wiki_branch: main`), le wiki est publié sur les deux forges ; relus :
|
||||||
|
même arbre (`b3a70ba`), 96 pages, 8 figures, des deux côtés. Le témoin ne retient qu'une
|
||||||
|
forge (la dernière publiée, eregion) : P60 ne voit pas l'autre.
|
||||||
|
|
||||||
## 2026-10-07 (104) — L'historique de supervision traverse la reconstruction : les deux locataires, sur `2cbfa2d`
|
## 2026-10-07 (104) — L'historique de supervision traverse la reconstruction : les deux locataires, sur `2cbfa2d`
|
||||||
|
|
||||||
**94 preuves.** Les deux locataires rasés et reconstruits par le site qui les nomme, sur le
|
**94 preuves.** Les deux locataires rasés et reconstruits par le site qui les nomme, sur le
|
||||||
|
|
|
||||||
21
CLAUDE.md
21
CLAUDE.md
|
|
@ -4,24 +4,3 @@
|
||||||
|
|
||||||
`AGENTS.md` est la **source d'autorité unique** de ce dépôt. Claude Code doit le lire
|
`AGENTS.md` est la **source d'autorité unique** de ce dépôt. Claude Code doit le lire
|
||||||
et le respecter intégralement avant toute modification.
|
et le respecter intégralement avant toute modification.
|
||||||
|
|
||||||
En cas de contradiction entre `CLAUDE.md` et `AGENTS.md`, **suivre `AGENTS.md`**.
|
|
||||||
|
|
||||||
Ce fichier est volontairement mince : il ne redéclare pas la doctrine (template, SSH,
|
|
||||||
pare-feu, cloud-init, handlers, Makefile, groupes…). Toute cette doctrine vit dans
|
|
||||||
`AGENTS.md`, pour éviter la duplication et les dérives qu'elle provoque.
|
|
||||||
|
|
||||||
## Les cinq règles absolues
|
|
||||||
|
|
||||||
1. **Un seul agent IA** travaille dans ce dépôt à la fois (Codex *ou* Claude, jamais les deux).
|
|
||||||
2. **`instance/inventories/*/hosts.yml` est un artefact GÉNÉRÉ** depuis le plan
|
|
||||||
(`instance/plan/`). On l'édite jamais à la main : on édite le plan, puis
|
|
||||||
`make instancier` → `make instancier-appliquer`.
|
|
||||||
3. **Aucun secret en clair** (mots de passe, clés privées, tokens, certificats) — Vault
|
|
||||||
ou stockage hors dépôt uniquement.
|
|
||||||
4. **Toute action destructive ou risquée exige une confirmation explicite** (ex.
|
|
||||||
`CONFIRMER=true`, `confirm_destructive_action: true`). Sans elle, refuser l'exécution.
|
|
||||||
5. **Rien n'est « prêt » sans validation.** Au minimum `--syntax-check` du playbook
|
|
||||||
touché, `ansible-lint` si disponible, et l'entrée `CHANGELOG.md` correspondante.
|
|
||||||
|
|
||||||
Le détail de chacune de ces règles, et tout le reste, est dans `AGENTS.md`.
|
|
||||||
|
|
|
||||||
6
Makefile
6
Makefile
|
|
@ -318,6 +318,10 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire)
|
||||||
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_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
|
||||||
|
|
@ -330,6 +334,8 @@ 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
|
||||||
|
|
|
||||||
130
docs/audit/preuve-2026-10-09.md
Normal file
130
docs/audit/preuve-2026-10-09.md
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
# Preuve de conformite — Set-OPS — 2026-10-09
|
||||||
|
|
||||||
|
> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer
|
||||||
|
> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;
|
||||||
|
> aucune validation n'est reimplementee ici. Voir le mode d'emploi :
|
||||||
|
> [`docs/audit/README.md`](README.md), et le registre trace :
|
||||||
|
> [`docs/audit/affirmations.md`](affirmations.md).
|
||||||
|
|
||||||
|
- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml`
|
||||||
|
- **Verdict** : ✅ CONFORME (94 OK · 0 echec · 0 saute)
|
||||||
|
|
||||||
|
## Preuves
|
||||||
|
|
||||||
|
| # | Preuve | Affirmations | Statut | Detail |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | } \| to_nice_json }}`. |
|
||||||
|
| P02 | Tests unitaires (inventaire, raser, ecritures du plan, rendu du GUI) | — | ✅ OK | OK |
|
||||||
|
| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 3 instance(s) verifiee(s) — OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre : plan et inventaire applique coincident. |
|
||||||
|
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
|
||||||
|
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
|
||||||
|
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
|
||||||
|
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check), 1 nom(s) surveille(s) sans reference orpheline. |
|
||||||
|
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 42 groupes classes, aucun cycle, aucune arete en arriere ; playbooks/site.yml a jour. |
|
||||||
|
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 40 rôles, 119 flux, schéma + matrice OK. |
|
||||||
|
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
|
||||||
|
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_resolveur_site |
|
||||||
|
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
|
||||||
|
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
|
||||||
|
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
|
||||||
|
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
|
||||||
|
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 13 hotes, 33 groupes (inventaire dechiffre et parse). |
|
||||||
|
| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. |
|
||||||
|
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 35 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) |
|
||||||
|
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 30 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
|
||||||
|
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) et 7 zone(s) de site : adressage 100% derive du seed index. |
|
||||||
|
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 4 instance(s) federee(s), aucun index en collision. |
|
||||||
|
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). |
|
||||||
|
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 14 reseau(x), aucune collision avec la plage tenant. |
|
||||||
|
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | note : serveur_powerdns declare un port `derive` que le plan du site ne resout pas — aucune regle emise. |
|
||||||
|
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 2 tenant(s), 40 groupe(s), 108 regle(s). |
|
||||||
|
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 13 hote(s) x 6 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). |
|
||||||
|
| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. |
|
||||||
|
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 35 VM placee(s), aucun nom ni VMID en collision. |
|
||||||
|
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 34 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 23, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
|
||||||
|
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 2 zone(s), 12 VNet(s), 12 sous-reseau(x), aucune collision. |
|
||||||
|
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 78 scripts expliques et atteignables, 151 cibles make documentees, 69 roles avec README. |
|
||||||
|
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (148 cle(s) declaree(s) par l'instance). |
|
||||||
|
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 44 revendication(s) de port, aucune collision entre roles co-localises (35 groupes). |
|
||||||
|
| P34 | Chaque document declare son lecteur | — | ✅ OK | 50 document(s) declarent leur lecteur (47 genere(s) exempte(s)). |
|
||||||
|
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 4 application(s) exigeant une base l'ont toutes (4 entree(s) au registre). |
|
||||||
|
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 9 hote(s) de l'ecosysteme et 3 du site detiennent de l'etat, tous porteurs de `client_backup` (11 groupe(s) au catalogue). |
|
||||||
|
| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. |
|
||||||
|
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 42 role(s) serveur/client tous nommes, 42 groupe(s) cite(s) en table existent tous. |
|
||||||
|
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 97 page(s) de wiki toutes atteignables. |
|
||||||
|
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
|
||||||
|
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 74 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
|
||||||
|
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 3 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal). |
|
||||||
|
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 9 machine(s) du plan retrouvees, 201 regle(s) du site. |
|
||||||
|
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 integration(s) appliquent leur serveur avant leurs clients. |
|
||||||
|
| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). |
|
||||||
|
| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos |
|
||||||
|
| P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). |
|
||||||
|
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 102 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. |
|
||||||
|
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (137 lignes). |
|
||||||
|
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 304 regles `pass`), tous non consignes et tous motives. |
|
||||||
|
| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 |
|
||||||
|
| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. |
|
||||||
|
| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 13 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere : WAN muet, interfa |
|
||||||
|
| P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 9 role(s) applique(s), aucun secret de tenant reclame. |
|
||||||
|
| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 13 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 13. |
|
||||||
|
| P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. |
|
||||||
|
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (94 preuves, 69 roles, 42 groupes). |
|
||||||
|
| P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. |
|
||||||
|
| P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). |
|
||||||
|
| P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `e9215a2` (publie le 2026-10-07). |
|
||||||
|
| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 56 champ(s) sur 7 registres ; il couvre tout ce que les plans reels contiennent, et la FORME de chaque champ (scalaire / objet / table) corresp |
|
||||||
|
| P62 | Schema du plan : il decrit tout ce que le MOTEUR accepte | AFF-033 | ✅ OK | Les 4 validateurs n'acceptent aucun champ que le schema ignore (applications:8, bases_donnees:4, domaines_publics:9, serveurs:3 champ(s) lus par validateur). |
|
||||||
|
| P63 | cloud-init nait avec la VM et ne lui survit pas | — | ✅ OK | cloud-init est au gabarit (la premiere seconde), absent du socle (pas de va-et-vient), et retire par le durcissement — avec la garde qui verifie que le reseau s |
|
||||||
|
| P64 | Sondes de supervision : declarees ET deposees | — | ✅ OK | 47 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_journal/journaux, client_metrique/metriques, client_pki/certificat, client_s |
|
||||||
|
| P65 | Depots tiers : demandes au cache, jamais en HTTPS direct | — | ✅ OK | 4 depot(s) tiers relaye(s) par le cache, aucun role ne les vise en https:// ecrit en dur. |
|
||||||
|
| P66 | Clients OIDC : chaque URI vise un nom que le plan expose | — | ✅ OK | 4 client(s) OIDC, toutes leurs URI visent un FQDN que le plan expose (6 exposition(s)). |
|
||||||
|
| P67 | Nom public : le service porte celui du plan, pas celui du role | — | ✅ OK | 13 service(s) expose(s) portent le nom du plan, sur 2 inventaire(s) : instance, SITE. |
|
||||||
|
| P68 | Cle de depot telechargee : mesuree avant d'etre utilisee | — | ✅ OK | 5 role(s) telechargent une cle de depot, tous la mesurent avant de s'en servir. |
|
||||||
|
| P69 | Amorcage d'un tenant : l'adresse designe le site REEL | — | ✅ OK | 2 adresse(s) d'amorcage designent bien une machine du site. |
|
||||||
|
| P70 | Depot de binaires : il tient tout ce que les roles vont chercher | — | ✅ OK | 27 artefact(s) direct(s) tenus par le depot du site. |
|
||||||
|
| P71 | Pool du site : le genome ne nait pas chez un tenant | — | ✅ OK | `site-creer` nomme `--pool-site` ; le pool du genome ne peut plus etre celui d'un tenant. |
|
||||||
|
| P72 | Annuaire : aucun service ne se lie avec le compte du maitre | — | ✅ OK | 5 role(s) consultent l'annuaire, chacun avec SON compte de service ; seul `amorcage_acces` garde celui d'administration, et il provisionne au lieu de consommer. |
|
||||||
|
| P73 | Le locataire designe les services de son site REEL | — | ✅ OK | CONFORME : 10 intrant(s) du locataire concordent avec ce que le site expose (1 non declare(s), donc derive(s) ou non utilise(s)). |
|
||||||
|
| P74 | Gabarit dore : une seule declaration, au plan du site | — | ✅ OK | Gabarit declare une seule fois : VMID 9006 « modeleSetOPS-minimal ». |
|
||||||
|
| P75 | Les parametres de clonage traversent les trois maillons | — | ✅ OK | 14 parametre(s) de clonage, tous emis par l'inventaire. |
|
||||||
|
| P76 | Tout gabarit de role se rend vraiment | — | ✅ OK | 189 gabarits de role : tous se rendent. |
|
||||||
|
| P77 | Panneaux declares : assemblables, et gradues | — | ✅ OK | 8 panneau(x) declare(s) dans 2 role(s), tous avec titre, expression, raison et une unite que la table sait traduire. |
|
||||||
|
| P78 | Un consommateur de base suit le verrou TLS de son serveur | — | ✅ OK | 3 consommateur(s) suivent la posture de leur serveur ; 2 sans reglage TLS (serveur_icingaweb2, serveur_nextcloud). |
|
||||||
|
| P79 | Replis silencieux : une derivation vide ne passe pas pour un succes | — | ✅ OK | 4 ecosysteme(s) (OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, SITE-Chezlepro) : pattes, edges, certificats, rechargements, jumeaux d'amorcage, sorties du |
|
||||||
|
| P80 | Remise au client : inscrite, nommee, et son second temps a l'heure | — | ✅ OK | Aucun ecosysteme remis a un client : rien a tenir. |
|
||||||
|
| P81 | La console dit sa portee, et ne sert pas un inventaire vide en silence | — | ✅ OK | Portee `poste` derivee des voutes portees, source d'inventaire `instance/inventories/principal/hosts.yml`, et 15 route(s) POST exigent toutes un pouvoir. |
|
||||||
|
| P82 | DNS public : les zones publiees sont servies, signees avant d'etre exposees | — | ✅ OK | 2 zone(s) publique(s) declaree(s), toutes servies par le site ; 2 replication(s) declaree(s) des deux cotes ; transfert ouvert a la cle seule ; aucune expositio |
|
||||||
|
| P83 | Assistants : le registre des runbooks ne prend pas de retard sur le Makefile | — | ✅ OK | 17 runbooks, 144 etapes, 150 cibles documentees : chacune portee par un assistant ou exemptee avec son motif. |
|
||||||
|
| P84 | La fiche du site dit exactement ce que chaque locataire porte | — | ✅ OK | 2 fiche(s) de site : chacune dit exactement ce que son locataire porte (copies, racine, inventaire genere). |
|
||||||
|
| P85 | La face reseau du locataire dit exactement ce que le site en tire | — | ✅ OK | 2 face(s) reseau : chacune dit exactement ce que le site en tire (decouverte, devis Proxmox, frontiere, DNS public, sauvegarde). |
|
||||||
|
| P86 | Machine par machine, le locataire et Proxmox admettent les memes entrees | — | ✅ OK | 2 locataire(s) : machine par machine et port par port, nftables et Proxmox admettent les memes sources. |
|
||||||
|
| P87 | La frontiere designe chaque locataire par les identites qu'il publie | — | ✅ OK | 2 locataire(s) : la frontiere les designe par les identites qu'ils publient (supernet, administration, tunnel, groupes, routes, sortie). |
|
||||||
|
| P88 | Ce qui entre depuis l'Internet chez un locataire, la frontiere le tient de lui | — | ✅ OK | 2 locataire(s) : regles WAN, redirections et tunnel disent exactement ce que leur face reseau ouvre a tous. |
|
||||||
|
| P89 | Ce que l'administration atteint chez un locataire, la frontiere le tient de lui | — | ✅ OK | 2 locataire(s) : par la gestion, le VPN et le tunnel, l'administration atteint exactement ce que leur face reseau lui ouvre. |
|
||||||
|
| P90 | Ce qui sort d'un locataire vers l'Internet, la frontiere le tient de lui | — | ✅ OK | 2 locataire(s) : la frontiere laisse sortir exactement ce que leur face reseau publie. |
|
||||||
|
| P91 | La fiche du site est deposee a jour, et l'inventaire se genere sans le site | — | ✅ OK | 2 locataire(s) : fiche du site deposee a jour, et inventaire genere sans le site identique a l'octet pres. |
|
||||||
|
| P92 | Chaque locataire a publie sa face reseau a jour | — | ✅ OK | 2 locataire(s) : face reseau publiee, a jour. |
|
||||||
|
| P93 | La face publie, machine par machine, les parametres de clonage de l'inventaire | — | ✅ OK | 26 machine(s) : la face publie exactement les parametres de clonage de l'inventaire. |
|
||||||
|
| P94 | Creer, raser et placer un locataire par sa face visent les memes machines | — | ✅ OK | 2 locataire(s) : creer, raser et placer visent par la face les memes machines que par l'instance montee. |
|
||||||
|
|
||||||
|
## Couverture des affirmations ✅ du registre
|
||||||
|
|
||||||
|
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
|
||||||
|
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
|
||||||
|
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
|
||||||
|
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
|
||||||
|
elles restent hors du harnais recurrent (rien d'executable a rejouer).
|
||||||
|
|
||||||
|
## Declarations d'intention (⚪ invérifiables localement — assumees)
|
||||||
|
|
||||||
|
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
|
||||||
|
comme declarations d'intention, non comme preuves :
|
||||||
|
|
||||||
|
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
|
||||||
|
contre une flotte vivante.
|
||||||
|
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
|
||||||
|
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
|
||||||
|
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
|
||||||
|
|
||||||
|
_Rapport genere le 2026-10-09._
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ 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 | 46 | `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 |
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,31 @@ Un **résolveur** (miroir de `instancier`) agrège, par serveur, les `flux.yml`
|
||||||
- **le registre d'audit** : `docs/registre-flux.md` (généré) + une cible `make flux`.
|
- **le registre d'audit** : `docs/registre-flux.md` (généré) + une cible `make flux`.
|
||||||
|
|
||||||
## Activation prudente
|
## Activation prudente
|
||||||
|
|
||||||
|
### Descriptions conservées par les pare-feux
|
||||||
|
|
||||||
|
La `raison` non vide du flux et son rôle alimentent les champs natifs : `comment`
|
||||||
|
pour nftables et Proxmox, `description` pour le filtrage OPNsense (`descr` pour ses
|
||||||
|
redirections). Un commentaire de fichier `# ...` ne survit pas au chargement nftables.
|
||||||
|
Les règles communes (administration, connexions établies, refus) portent aussi leur motif.
|
||||||
|
|
||||||
|
Les libellés sont sur une ligne. Le commentaire nftables est borné à 127 octets UTF-8 ;
|
||||||
|
OPNsense garde sa clé `setops:...` complète et borne le texte qui suit pour rester dans
|
||||||
|
255 octets. Une raison abrégée reste disponible en entier dans le registre des flux.
|
||||||
|
Le commentaire Proxmox conserve aussi le pair nommé dans le devis.
|
||||||
|
|
||||||
|
Une correction de description apparaît dans `make proxmox-fw-plan` et
|
||||||
|
`make frontiere-plan`. Son application met à jour le champ sur la règle existante,
|
||||||
|
puis le relit : elle ne recrée pas la règle et ne modifie ni son activation ni sa
|
||||||
|
politique. Les confirmations habituelles restent requises pour appliquer ces devis.
|
||||||
|
`make test` vérifie notamment cette convergence et les limites des champs.
|
||||||
|
|
||||||
|
Références des champs natifs : [nftables](https://netfilter.org/projects/nftables/manpage.html),
|
||||||
|
[API Proxmox](https://github.com/proxmox/pve-firewall/blob/master/src/PVE/API2/Firewall/Rules.pm),
|
||||||
|
[descriptions OPNsense](https://github.com/opnsense/core/blob/master/src/opnsense/mvc/app/models/OPNsense/Base/FieldTypes/DescriptionField.php).
|
||||||
|
|
||||||
|
### Activer le filtrage
|
||||||
|
|
||||||
Activer nftables = **action destructive** (peut couper l'accès) → confirmation explicite +
|
Activer nftables = **action destructive** (peut couper l'accès) → confirmation explicite +
|
||||||
déploiement graduel (garder l'accès SSH/Ansible, tester par nœud).
|
déploiement graduel (garder l'accès SSH/Ansible, tester par nœud).
|
||||||
|
|
||||||
|
|
@ -130,4 +155,3 @@ ignorée, ce qui est pire que de ne pas l'avoir.
|
||||||
**Corollaire à retenir** : un port qu'on **subit** (le défaut amont d'un logiciel) doit être
|
**Corollaire à retenir** : un port qu'on **subit** (le défaut amont d'un logiciel) doit être
|
||||||
imposé et déclaré comme les autres. Celui d'Alloy ne l'était pas, et c'est la seule raison
|
imposé et déclaré comme les autres. Celui d'Alloy ne l'était pas, et c'est la seule raison
|
||||||
pour laquelle la collision a pu durer des semaines.
|
pour laquelle la collision a pu durer des semaines.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -430,3 +430,12 @@ Reste à trancher sur une interface **physique** : `ip ?`, `access-group ?`, `sp
|
||||||
déclare 53 en UDP et TCP, la récursion depuis la racine que le choix souverain implique.
|
déclare 53 en UDP et TCP, la récursion depuis la racine que le choix souverain implique.
|
||||||
Le `block out` de la section 5 est donc un vrai default-deny **assumé**, et le devis
|
Le `block out` de la section 5 est donc un vrai default-deny **assumé**, et le devis
|
||||||
l'énonce désormais au lieu de le poser en silence.
|
l'énonce désormais au lieu de le poser en silence.
|
||||||
|
- **La sortie générale n'existe plus — 2026-10-10.** Le 123 était déjà passé à la frontière
|
||||||
|
(2026-09-11). Le 443 et le 80 du socle sont devenus conditionnels (`seulement_si:
|
||||||
|
artefacts_amorcage` vide) : le site et ses locataires prennent apt par le cache du site,
|
||||||
|
et ne les reçoivent plus. Mesurée à la frontière, cette sortie ne servait plus qu'à ce
|
||||||
|
qu'aucun rôle ne déclarait : télémétrie de Grafana, d'Alloy et de Loki et vérification
|
||||||
|
de version de Collabora (coupées à la source), cartes de rspamd et repère du génome
|
||||||
|
(déclarés dans `serveur_rspamd` et `serveur_ops_site`). Chaque sortie vers Internet porte
|
||||||
|
désormais le nom du rôle qui en a besoin. `test_conditions_site.py` refuse qu'un rôle
|
||||||
|
universel en rouvre une sans condition.
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
| `serveur_backup_site` | ingress | 22 | tcp | voisins_site | ssh | Les écosystèmes de ce site déposent leur état ici tant qu'ils n'ont pas leur propre dépôt. SFTP par un utilisateur restreint, jamais un compte d'administration : le site reçoit des octets chiffrés par restic, il ne peut pas les lire. |
|
| `serveur_backup_site` | ingress | 22 | tcp | voisins_site | ssh | Les écosystèmes de ce site déposent leur état ici tant qu'ils n'ont pas leur propre dépôt. SFTP par un utilisateur restreint, jamais un compte d'administration : le site reçoit des octets chiffrés par restic, il ne peut pas les lire. |
|
||||||
| `serveur_cache_site` | ingress | 3142 | tcp | voisins_site | clair | Servir les caches des écosystèmes voisins. Debian n'est ainsi téléchargé qu'une fois pour tout le site, et le cache ne voit que des requêtes AGRÉGÉES — jamais quelle machine installe quoi. |
|
| `serveur_cache_site` | ingress | 3142 | tcp | voisins_site | clair | Servir les caches des écosystèmes voisins. Debian n'est ainsi téléchargé qu'une fois pour tout le site, et le cache ne voit que des requêtes AGRÉGÉES — jamais quelle machine installe quoi. |
|
||||||
| `serveur_cache_site` | egress | 80 | tcp | externe | clair | Remplir le cache depuis les dépôts Debian amont. En clair parce que les dépôts apt sont signés : l'intégrité vient de la signature, pas du transport. |
|
| `serveur_cache_site` | egress | 80 | tcp | externe | clair | Remplir le cache depuis les dépôts Debian amont. En clair parce que les dépôts apt sont signés : l'intégrité vient de la signature, pas du transport. |
|
||||||
| `serveur_cache_site` | egress | 443 | tcp | externe | tls-requis | Les dépôts tiers qui n'existent qu'en HTTPS (smallstep, Grafana, Icinga). Le cache les relaie pour que la flotte n'ait pas à sortir elle-même. |
|
| `serveur_cache_site` | egress | 443 | tcp | externe | tls-requis | Les dépôts tiers qui n'existent qu'en HTTPS (smallstep, Grafana, Icinga), et le dépôt de binaires (roues PyPI, collections Galaxy, versions GitHub, Nextcloud, Forgejo). Le site les relaie pour que la flotte n'ait pas à sortir elle-même. |
|
||||||
| `serveur_collabora` | ingress | 9980 | tcp | edge | clair | Éditeur servi au navigateur via l'edge (WebSocket WOPI ; TLS terminé à l'edge). |
|
| `serveur_collabora` | ingress | 9980 | tcp | edge | clair | Éditeur servi au navigateur via l'edge (WebSocket WOPI ; TLS terminé à l'edge). |
|
||||||
| `serveur_collabora` | ingress | 9980 | tcp | localhost | clair | Vérifications WOPI serveur→Collabora depuis Nextcloud co-localisé. |
|
| `serveur_collabora` | ingress | 9980 | tcp | localhost | clair | Vérifications WOPI serveur→Collabora depuis Nextcloud co-localisé. |
|
||||||
| `serveur_debian` | ingress | 22 | tcp | flotte, externe | ssh | Plan de gestion : administration et déploiement Ansible par SSH (inter-nœud ; l'accès depuis l'extérieur est filtré à l'OPNsense). |
|
| `serveur_debian` | ingress | 22 | tcp | flotte, externe | ssh | Plan de gestion : administration et déploiement Ansible par SSH (inter-nœud ; l'accès depuis l'extérieur est filtré à l'OPNsense). |
|
||||||
|
|
@ -31,9 +31,9 @@
|
||||||
| `serveur_debian` | ingress | frag-needed | icmp | externe | n-a | ICMP « fragmentation nécessaire » entrant : sans lui, un distant ne peut pas nous demander de réduire nos paquets — les transferts se figent. |
|
| `serveur_debian` | ingress | frag-needed | icmp | externe | n-a | ICMP « fragmentation nécessaire » entrant : sans lui, un distant ne peut pas nous demander de réduire nos paquets — les transferts se figent. |
|
||||||
| `serveur_debian` | egress | 53 | udp | frontiere | n-a | Résolution de noms à l'amorçage, avant que le résolveur de l'écosystème n'existe. Sans elle, les machines d'un site neuf ne peuvent pas résoudre leurs dépôts de paquets — et rien ne peut donc s'installer, y compris le résolveur lui-même. |
|
| `serveur_debian` | egress | 53 | udp | frontiere | n-a | Résolution de noms à l'amorçage, avant que le résolveur de l'écosystème n'existe. Sans elle, les machines d'un site neuf ne peuvent pas résoudre leurs dépôts de paquets — et rien ne peut donc s'installer, y compris le résolveur lui-même. |
|
||||||
| `serveur_debian` | egress | 53 | tcp | frontiere | n-a | Réponses longues et bascule TCP, obligatoires en DNS. Déclarer l'UDP sans le TCP donne une résolution qui marche jusqu'à la première réponse tronquée. |
|
| `serveur_debian` | egress | 53 | tcp | frontiere | n-a | Réponses longues et bascule TCP, obligatoires en DNS. Déclarer l'UDP sans le TCP donne une résolution qui marche jusqu'à la première réponse tronquée. |
|
||||||
| `serveur_debian` | egress | 80 | tcp | externe | clair | Dépôts apt en clair et redirections HTTP des miroirs (l'intégrité vient de la signature des paquets, pas du transport). |
|
| `serveur_debian` | egress | 80 | tcp | externe | clair | Dépôts apt en clair et redirections des miroirs, pour un écosystème SANS amont d'artefacts. L'intégrité vient de la signature des paquets, pas du transport. |
|
||||||
| `serveur_debian` | egress | 123 | udp | frontiere | n-a | Synchronisation d'horloge (NTP) contre la frontière, autorité de temps de l'écosystème. Une dérive fait échouer la validation des certificats step-ca et l'authentification SSO. |
|
| `serveur_debian` | egress | 123 | udp | frontiere | n-a | Synchronisation d'horloge (NTP) contre la frontière, autorité de temps de l'écosystème. Une dérive fait échouer la validation des certificats step-ca et l'authentification SSO. |
|
||||||
| `serveur_debian` | egress | 443 | tcp | externe | tls-requis | Dépôts apt en HTTPS (Debian, Smallstep, Grafana, Icinga, Forgejo, Nextcloud) — sans quoi aucun correctif de sécurité n'entre. |
|
| `serveur_debian` | egress | 443 | tcp | externe | tls-requis | Dépôts apt en direct, pour un écosystème SANS aucune source d'artefacts. Le site et ses locataires passent par le cache du site et ne la reçoivent pas. |
|
||||||
| `serveur_debian` | egress | frag-needed | icmp | externe | n-a | ICMP « fragmentation nécessaire » sortant : c'est ainsi que nos hôtes signalent l'overlay à 1450 aux correspondants distants. |
|
| `serveur_debian` | egress | frag-needed | icmp | externe | n-a | ICMP « fragmentation nécessaire » sortant : c'est ainsi que nos hôtes signalent l'overlay à 1450 aux correspondants distants. |
|
||||||
| `serveur_dns_public` | ingress | 53 | udp | voisins_site | clair | NOTIFY des primaires des locataires du site : une zone publique a change. Le contenu d'une zone publique n'a rien de secret ; ce qui compte est QUI peut le modifier, et c'est TSIG qui le garde, sur le transfert. |
|
| `serveur_dns_public` | ingress | 53 | udp | voisins_site | clair | NOTIFY des primaires des locataires du site : une zone publique a change. Le contenu d'une zone publique n'a rien de secret ; ce qui compte est QUI peut le modifier, et c'est TSIG qui le garde, sur le transfert. |
|
||||||
| `serveur_dns_public` | ingress | 53 | tcp | voisins_site | clair | Repli TCP des notifications des primaires des locataires. |
|
| `serveur_dns_public` | ingress | 53 | tcp | voisins_site | clair | Repli TCP des notifications des primaires des locataires. |
|
||||||
|
|
@ -89,6 +89,7 @@
|
||||||
| `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). |
|
||||||
|
|
@ -119,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. |
|
||||||
|
|
@ -128,10 +131,10 @@
|
||||||
|
|
||||||
## 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
|
||||||
- **tls** : 10 flux
|
- **tls** : 10 flux
|
||||||
- **tls-cible** : 2 flux
|
- **tls-cible** : 2 flux
|
||||||
- **tls-requis** : 34 flux
|
- **tls-requis** : 36 flux
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,29 @@ 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:
|
serveur_icinga:
|
||||||
# L'AC D'ICINGA FAIT L'IDENTITE DE SA BASE (2026-10-07). Icinga 2 derive l'environnement
|
# 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'Icinga DB de la cle publique de son AC, et chaque identifiant d'hote, de service et
|
||||||
|
|
|
||||||
84
roles/client_backup/files/setops-copie-a-froid.sh
Normal file
84
roles/client_backup/files/setops-copie-a-froid.sh
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Géré par Set-OPS (rôle client_backup). Ne pas éditer à la main.
|
||||||
|
#
|
||||||
|
# setops-copie-a-froid SERVICE SOURCE DESTINATION [--blocs-immuables] [--lier REL]... [--copier REL]...
|
||||||
|
#
|
||||||
|
# Copie COHERENTE d'une base qui ecrit en continu (Prometheus, Loki) : le service est
|
||||||
|
# arrete le temps de la copie, puis redemarre — QUOI QU'IL ARRIVE a la copie, et seulement
|
||||||
|
# s'il tournait avant. La destination n'est remplacee qu'une fois la copie complete.
|
||||||
|
#
|
||||||
|
# POURQUOI A FROID (decision de l'exploitant, 2026-10-07 : « on ne prend pas de risque »).
|
||||||
|
# Copier a chaud un WAL qu'on ecrit, c'est sauvegarder un fichier a moitie ecrit. L'autre
|
||||||
|
# voie — ne garder que les blocs fermes — perdait jusqu'aux deux dernieres heures de
|
||||||
|
# metriques a chaque reconstruction. Mesure au site : 2,9 Go de Prometheus se lisent en
|
||||||
|
# 4,2 s, son WAL seul en 0,3 s ; Loki entier en 2 s.
|
||||||
|
#
|
||||||
|
# `--blocs-immuables` : les sous-dossiers nommes par un ULID (26 caracteres, `01…`) sont
|
||||||
|
# les blocs de Prometheus, que rien ne reecrit apres leur fermeture. On les recopie par
|
||||||
|
# LIENS DURS — instantane, sans place — et le reste (WAL, `chunks_head`) par une vraie
|
||||||
|
# copie. Si la destination est sur un autre systeme de fichiers, on copie tout.
|
||||||
|
#
|
||||||
|
# `--lier REL` lie tout un sous-dossier ; `--copier REL` y copie vraiment un sous-dossier qui,
|
||||||
|
# lui, bouge (2026-10-07). Pour Loki : `--lier chunks --copier chunks/index`. Ses morceaux sont
|
||||||
|
# adresses par leur contenu (`debut:fin:somme`, decode sur site-mon-01) et ne bougent jamais ;
|
||||||
|
# mais `chunks/index/` loge aussi `delete_requests.gz`, la base du compacteur, mesuree
|
||||||
|
# reecrite trois semaines apres sa naissance. La lier, c'etait laisser la sauvegarde changer
|
||||||
|
# avec elle. Copier 6 550 morceaux arretait Loki 13 s ; les lier prend 40 ms.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
service="${1:?service}"
|
||||||
|
source="${2:?source}"
|
||||||
|
dest="${3:?destination}"
|
||||||
|
shift 3
|
||||||
|
blocs="" lier=() copier=()
|
||||||
|
while (( $# )); do
|
||||||
|
case "$1" in
|
||||||
|
--blocs-immuables) blocs=1; shift ;;
|
||||||
|
--lier) lier+=("$2"); shift 2 ;;
|
||||||
|
--copier) copier+=("$2"); shift 2 ;;
|
||||||
|
*) echo "copie a froid : option inconnue : $1" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ -d "${source}" ]] || { echo "copie a froid : ${source} n'existe pas — rien a copier." >&2; exit 0; }
|
||||||
|
|
||||||
|
tmp="${dest}.en-cours"
|
||||||
|
rm -rf "${tmp}"
|
||||||
|
mkdir -p "$(dirname "${dest}")"
|
||||||
|
|
||||||
|
actif=0
|
||||||
|
if systemctl is-active --quiet "${service}"; then actif=1; fi
|
||||||
|
relancer() { if (( actif )); then systemctl start "${service}"; fi; }
|
||||||
|
|
||||||
|
debut=$(date +%s%N)
|
||||||
|
if (( actif )); then systemctl stop "${service}"; fi
|
||||||
|
trap relancer EXIT
|
||||||
|
|
||||||
|
lie() { cp -al "$1" "$2" 2>/dev/null || { rm -rf "$2"; cp -a "$1" "$2"; }; }
|
||||||
|
mkdir -p "${tmp}"
|
||||||
|
for e in "${source}"/* "${source}"/.[!.]*; do
|
||||||
|
[[ -e "${e}" ]] || continue
|
||||||
|
n="$(basename "${e}")"
|
||||||
|
if [[ -n "${blocs}" && -d "${e}" && ${#n} -eq 26 && "${n}" == 01* ]] \
|
||||||
|
|| [[ " ${lier[*]:-} " == *" ${n} "* ]]; then
|
||||||
|
lie "${e}" "${tmp}/${n}"
|
||||||
|
else
|
||||||
|
cp -a "${e}" "${tmp}/${n}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
# Ce qui bouge, a l'interieur d'un dossier lie : remplace par une vraie copie.
|
||||||
|
for c in "${copier[@]:+${copier[@]}}"; do
|
||||||
|
if [[ -e "${source}/${c}" ]]; then
|
||||||
|
rm -rf "${tmp:?}/${c}"
|
||||||
|
mkdir -p "$(dirname "${tmp}/${c}")"
|
||||||
|
cp -a "${source}/${c}" "${tmp}/${c}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
trap - EXIT
|
||||||
|
relancer
|
||||||
|
fin=$(date +%s%N)
|
||||||
|
|
||||||
|
rm -rf "${dest}"
|
||||||
|
mv "${tmp}" "${dest}"
|
||||||
|
echo "copie a froid de ${source} : ${service} $( (( actif )) && echo "arrete (jusqu a la relance, rejeu du WAL non compris) $(( (fin - debut) / 1000000 )) ms" || echo "n'etait pas actif")"
|
||||||
|
|
@ -19,7 +19,8 @@ VERDICTS, PAR JEU :
|
||||||
clés et certificats de l'AC, AC d'Icinga et son environnement, clé DKIM,
|
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
|
`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
|
base dont plus aucune ligne d'avant ne subsiste ; une ligne d'historique
|
||||||
perdue. Exactement ce que la reconstruction du
|
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.
|
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.
|
Code de sortie : 0 sans écart, 1 avec au moins un écart, 2 erreur d'usage.
|
||||||
|
|
@ -30,6 +31,7 @@ import argparse
|
||||||
import base64
|
import base64
|
||||||
import collections
|
import collections
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
@ -174,6 +176,80 @@ def instance_id(r: Rapport, avant: Path, vivant: Path) -> None:
|
||||||
r.ecarts.append(f"IDENTITE instanceid : {a} -> {v or '(absent)'}")
|
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 ----------------------------------------------------------------------
|
# --- L'annuaire ----------------------------------------------------------------------
|
||||||
|
|
||||||
def entrees_ldif(texte: str) -> dict[str, dict[str, list[str]]]:
|
def entrees_ldif(texte: str) -> dict[str, dict[str, list[str]]]:
|
||||||
|
|
@ -317,6 +393,8 @@ def main(argv: list[str] | None = None, interroger=psql) -> int:
|
||||||
ap.add_argument("--heure", default="")
|
ap.add_argument("--heure", default="")
|
||||||
ap.add_argument("--hote", default="")
|
ap.add_argument("--hote", default="")
|
||||||
ap.add_argument("--jeu", action="append", default=[], help="NOM=CHEMIN[:CHEMIN...]")
|
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", default="", help="chemin du LDIF dans l'instantané")
|
||||||
ap.add_argument("--ldif-vivant", type=Path, default=None)
|
ap.add_argument("--ldif-vivant", type=Path, default=None)
|
||||||
ap.add_argument("--dump", default="", help="chemin du pg_dumpall dans l'instantané")
|
ap.add_argument("--dump", default="", help="chemin du pg_dumpall dans l'instantané")
|
||||||
|
|
@ -327,9 +405,17 @@ def main(argv: list[str] | None = None, interroger=psql) -> int:
|
||||||
print(" SANS OBJET : ce noeud ne detient aucun etat.")
|
print(" SANS OBJET : ce noeud ne detient aucun etat.")
|
||||||
return 0
|
return 0
|
||||||
ecart = False
|
ecart = False
|
||||||
|
vivants = dict(v.partition("=")[::2] for v in a.vivant)
|
||||||
for spec in a.jeu:
|
for spec in a.jeu:
|
||||||
nom, _, chemins = spec.partition("=")
|
nom, _, chemins = spec.partition("=")
|
||||||
if nom == "openldap":
|
# 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)"
|
r, unite = jeu_annuaire(a.avant / a.ldif.lstrip("/"), a.ldif_vivant or Path("/nonexistent")), "entree(s)"
|
||||||
elif nom == "postgresql":
|
elif nom == "postgresql":
|
||||||
r, unite = jeu_postgresql(a.avant / a.dump.lstrip("/"), interroger), "table(s)"
|
r, unite = jeu_postgresql(a.avant / a.dump.lstrip("/"), interroger), "table(s)"
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
@ -116,6 +127,7 @@
|
||||||
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
|
- /etc/systemd/system/setops-sauvegarde-avant-raser.service
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@
|
||||||
# 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
|
||||||
|
|
@ -58,6 +60,9 @@ JEUX=({% for j in client_backup_jobs %} "{{ j.nom }}"{% endfor %} )
|
||||||
# Les chemins de chaque jeu, separes par « : » — ce que les temoins extraient et comparent.
|
# 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 %} )
|
declare -A CHEMINS_JEU=({% for j in client_backup_jobs %} ["{{ j.nom }}"]="{{ j.chemins | join(':') }}"{% endfor %} )
|
||||||
ETIQUETTE_AVANT_RASER="{{ client_backup_etiquette_avant_raser }}"
|
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; }
|
||||||
|
|
@ -154,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
|
||||||
|
|
@ -358,6 +369,7 @@ cmd_temoins() {
|
||||||
IFS=: read -r -a morceaux <<< "${CHEMINS_JEU[${j}]}"
|
IFS=: read -r -a morceaux <<< "${CHEMINS_JEU[${j}]}"
|
||||||
chemins+=("${morceaux[@]}")
|
chemins+=("${morceaux[@]}")
|
||||||
args+=(--jeu "${j}=${CHEMINS_JEU[${j}]}")
|
args+=(--jeu "${j}=${CHEMINS_JEU[${j}]}")
|
||||||
|
if [[ -n "${VIVANT_JEU[${j}]:-}" ]]; then args+=(--vivant "${j}=${VIVANT_JEU[${j}]}"); fi
|
||||||
done
|
done
|
||||||
extraire "${chemins[@]}"
|
extraire "${chemins[@]}"
|
||||||
if [[ -s "${TMP}${LDIF}" ]]; then
|
if [[ -s "${TMP}${LDIF}" ]]; then
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -21,3 +21,5 @@ client_backup_groupes_etat:
|
||||||
- serveur_rspamd
|
- serveur_rspamd
|
||||||
- serveur_web_dorsal
|
- serveur_web_dorsal
|
||||||
- serveur_icinga
|
- serveur_icinga
|
||||||
|
- serveur_prometheus
|
||||||
|
- serveur_loki
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
65
roles/common_packages/tasks/minuteurs.yml
Normal file
65
roles/common_packages/tasks/minuteurs.yml
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
---
|
||||||
|
# LE RATTRAPAGE HERITE DU GABARIT (2026-10-08).
|
||||||
|
#
|
||||||
|
# `apt-daily.timer` et `apt-daily-upgrade.timer` sont PERSISTANTS : systemd garde la date
|
||||||
|
# de leur dernier passage (`/var/lib/systemd/timers/stamp-*`) et rattrape un passage
|
||||||
|
# manque. Une VM clonee herite de ces traces — celle d'`apt-daily` datait du 2026-06-21,
|
||||||
|
# jour de fabrication du gabarit. Elle « rattrape » donc des son demarrage, avec un delai
|
||||||
|
# aleatoire jusqu'a 60 min : a la reconstruction de Technolibre, `apt-daily-upgrade` est
|
||||||
|
# parti cinq minutes apres la naissance d'`infra-pki-01`, en pleine serie d'operations apt
|
||||||
|
# du role. Il tenait le verrou du CACHE DES ARCHIVES, qu'`apt-get` n'attend jamais
|
||||||
|
# (`DPkg::Lock::Timeout` ne couvre que dpkg) :
|
||||||
|
#
|
||||||
|
# 'apt-get autoremove' failed: E: Could not get lock /var/cache/apt/archives/lock.
|
||||||
|
#
|
||||||
|
# Une trace plus ancienne que la naissance de la machine n'est pas la sienne : on la remet a
|
||||||
|
# l'heure, et on relance le minuteur, qui recalcule son prochain passage (le matin suivant)
|
||||||
|
# sans rattraper. Les mises a jour automatiques restent armees (decision du 2026-09-11) ;
|
||||||
|
# seul disparait un passage herite. Sur une machine en service, la trace est recente :
|
||||||
|
# rien ne change.
|
||||||
|
- name: Naissance de la machine (cle d'hote SSH, posee au premier demarrage)
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: /etc/ssh/ssh_host_ed25519_key.pub
|
||||||
|
register: common_packages_naissance
|
||||||
|
|
||||||
|
- name: Traces des minuteurs apt persistants
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: "/var/lib/systemd/timers/stamp-{{ item }}"
|
||||||
|
loop: "{{ common_packages_unites_maj_auto | select('match', '.*[.]timer$') | list }}"
|
||||||
|
register: common_packages_traces
|
||||||
|
|
||||||
|
- name: Remettre a l'heure une trace heritee du gabarit
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item.stat.path }}"
|
||||||
|
state: touch
|
||||||
|
mode: "0644"
|
||||||
|
loop: >-
|
||||||
|
{{ common_packages_traces.results | selectattr('stat.exists')
|
||||||
|
| selectattr('stat.mtime', 'lt', common_packages_naissance.stat.mtime | default(0))
|
||||||
|
| list }}
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.item }}"
|
||||||
|
register: common_packages_traces_remises
|
||||||
|
when: common_packages_naissance.stat.exists
|
||||||
|
|
||||||
|
# Relancer recalcule le prochain passage d'apres la trace neuve. Relancer un minuteur ne
|
||||||
|
# declenche pas sa cible.
|
||||||
|
- name: Relancer les minuteurs dont la trace a ete remise
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
name: "{{ item.item.item }}"
|
||||||
|
state: restarted
|
||||||
|
loop: "{{ common_packages_traces_remises.results | default([]) | selectattr('changed') | list }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.item.item }}"
|
||||||
|
|
||||||
|
# UN PASSAGE DEJA PARTI se termine avant nos operations apt, plutot que de se les disputer.
|
||||||
|
# `is-active` rend 0 si l'une au moins des unites tourne.
|
||||||
|
- name: Attendre la fin d'un passage apt automatique deja en cours
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv: ["systemctl", "is-active", "--quiet", "apt-daily.service", "apt-daily-upgrade.service"]
|
||||||
|
register: common_packages_passage
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
until: common_packages_passage.rc != 0
|
||||||
|
retries: 30
|
||||||
|
delay: 10
|
||||||
|
|
@ -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 %}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@
|
||||||
# Surcharge de l'unité du paquet. Le `coolwsd.xml` livré (439 lignes commentées) reste
|
# Surcharge de l'unité du paquet. Le `coolwsd.xml` livré (439 lignes commentées) reste
|
||||||
# intact : le remplacer par un gabarit maison obligerait à suivre son évolution à chaque
|
# intact : le remplacer par un gabarit maison obligerait à suivre son évolution à chaque
|
||||||
# version amont, et ferait perdre les commentaires qui expliquent chaque réglage.
|
# version amont, et ferait perdre les commentaires qui expliquent chaque réglage.
|
||||||
|
#
|
||||||
|
# AUCUNE VÉRIFICATION DE VERSION (mesure du 2026-10-10) : par défaut coolwsd interroge
|
||||||
|
# collabora.co.uk toutes les 10 heures, et le dit aux utilisateurs par une fenêtre. Les
|
||||||
|
# mises à jour arrivent par apt ; `fetch_update_check=0` coupe l'appel, la fenêtre suit.
|
||||||
[Service]
|
[Service]
|
||||||
ExecStart=
|
ExecStart=
|
||||||
ExecStart=/usr/bin/coolwsd --version --o:sys_template_path=/opt/cool/systemplate \
|
ExecStart=/usr/bin/coolwsd --version --o:sys_template_path=/opt/cool/systemplate \
|
||||||
|
|
@ -12,6 +16,8 @@ ExecStart=/usr/bin/coolwsd --version --o:sys_template_path=/opt/cool/systemplate
|
||||||
--o:ssl.termination={{ serveur_collabora_ssl_termination | bool | lower }} \
|
--o:ssl.termination={{ serveur_collabora_ssl_termination | bool | lower }} \
|
||||||
--o:server_name={{ serveur_collabora_server_name }} \
|
--o:server_name={{ serveur_collabora_server_name }} \
|
||||||
--o:storage.wopi.alias_group[0].host=https://{{ serveur_collabora_domaine_nextcloud }}:443 \
|
--o:storage.wopi.alias_group[0].host=https://{{ serveur_collabora_domaine_nextcloud }}:443 \
|
||||||
|
--o:fetch_update_check=0 \
|
||||||
|
--o:allow_update_popup=false \
|
||||||
--o:admin_console.enable={{ serveur_collabora_admin_actif | bool | lower }}{% if serveur_collabora_admin_actif | bool %} \
|
--o:admin_console.enable={{ serveur_collabora_admin_actif | bool | lower }}{% if serveur_collabora_admin_actif | bool %} \
|
||||||
--o:admin_console.username={{ serveur_collabora_admin_utilisateur }} \
|
--o:admin_console.username={{ serveur_collabora_admin_utilisateur }} \
|
||||||
--o:admin_console.password={{ serveur_collabora_admin_mot_de_passe }}{% endif %}
|
--o:admin_console.password={{ serveur_collabora_admin_mot_de_passe }}{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -9,22 +9,39 @@ flux:
|
||||||
chiffrement: ssh
|
chiffrement: ssh
|
||||||
raison: "Plan de gestion : administration et déploiement Ansible par SSH (inter-nœud ; l'accès depuis l'extérieur est filtré à l'OPNsense)."
|
raison: "Plan de gestion : administration et déploiement Ansible par SSH (inter-nœud ; l'accès depuis l'extérieur est filtré à l'OPNsense)."
|
||||||
|
|
||||||
# --- Sortie générale de la flotte ---
|
# --- Sortie apt directe : SEULEMENT SANS AMONT D'ARTEFACTS (2026-10-10) ---
|
||||||
# Portée par le socle parce qu'elle vaut pour TOUS les hôtes, sans exception.
|
#
|
||||||
# Non déclarée, la frontière la bloquerait : son `block out` final est un vrai
|
# C'ÉTAIT UNE SORTIE 80/443 VERS TOUT INTERNET, POUR TOUTE MACHINE. Sa raison, « dépôts
|
||||||
# default-deny, et la flotte perdrait ses mises à jour et son horloge en silence.
|
# apt », n'était plus vraie chez les locataires : leurs 26 machines prennent TOUTES leurs
|
||||||
|
# sources en `http://` par le cache du site (`artefacts_amorcage`), aucune n'a de dépôt
|
||||||
|
# HTTPS. Mesuré à la frontière, elle ne servait plus qu'à ce qu'aucun rôle ne déclarait :
|
||||||
|
# télémétrie de Grafana, d'Alloy et de Loki, vérification de version de Collabora,
|
||||||
|
# cartes de rspamd, repère du génome. Les trois premiers sont coupés à la source ; les
|
||||||
|
# deux derniers ont désormais leur propre flux, dans leur rôle.
|
||||||
|
#
|
||||||
|
# ELLE NE RESTE QUE LÀ OÙ IL N'Y A AUCUN AMONT. Le site non plus ne la reçoit pas : ses
|
||||||
|
# machines visent son propre cache depuis le 2026-09-14 (`site_inventaire.py`,
|
||||||
|
# `artefacts_amorcage`), et le cache, lui, porte ses propres sorties 80/443
|
||||||
|
# (`serveur_cache_site`, `serveur_artefacts`). Seul un écosystème sans aucune source
|
||||||
|
# d'artefacts — un laboratoire isolé — prend encore ses paquets en direct.
|
||||||
- sens: egress
|
- sens: egress
|
||||||
port: 443
|
port: 443
|
||||||
protocole: tcp
|
protocole: tcp
|
||||||
pair: externe
|
pair: externe
|
||||||
chiffrement: tls-requis
|
chiffrement: tls-requis
|
||||||
raison: "Dépôts apt en HTTPS (Debian, Smallstep, Grafana, Icinga, Forgejo, Nextcloud) — sans quoi aucun correctif de sécurité n'entre."
|
seulement_si: {variable: artefacts_amorcage, non_vide: false}
|
||||||
|
raison: >-
|
||||||
|
Dépôts apt en direct, pour un écosystème SANS aucune source d'artefacts. Le site
|
||||||
|
et ses locataires passent par le cache du site et ne la reçoivent pas.
|
||||||
- sens: egress
|
- sens: egress
|
||||||
port: 80
|
port: 80
|
||||||
protocole: tcp
|
protocole: tcp
|
||||||
pair: externe
|
pair: externe
|
||||||
chiffrement: clair
|
chiffrement: clair
|
||||||
raison: "Dépôts apt en clair et redirections HTTP des miroirs (l'intégrité vient de la signature des paquets, pas du transport)."
|
seulement_si: {variable: artefacts_amorcage, non_vide: false}
|
||||||
|
raison: >-
|
||||||
|
Dépôts apt en clair et redirections des miroirs, pour un écosystème SANS amont
|
||||||
|
d'artefacts. L'intégrité vient de la signature des paquets, pas du transport.
|
||||||
# --- Decouverte de MTU de chemin (PMTUD) ---
|
# --- Decouverte de MTU de chemin (PMTUD) ---
|
||||||
# L'overlay EVPN plafonne a 1450 : tout ce qui traverse la frontiere depend de ces
|
# L'overlay EVPN plafonne a 1450 : tout ce qui traverse la frontiere depend de ces
|
||||||
# deux messages. Bloques, la connexion s'etablit, les petites requetes passent et les
|
# deux messages. Bloques, la connexion s'etablit, les petites requetes passent et les
|
||||||
|
|
|
||||||
|
|
@ -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. #}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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') }}"
|
||||||
|
|
|
||||||
125
roles/serveur_nextcloud/tasks/app_tierce.yml
Normal file
125
roles/serveur_nextcloud/tasks/app_tierce.yml
Normal 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"
|
||||||
|
|
@ -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 }}"]
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 ----------------------------------------------------
|
||||||
#
|
#
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -435,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.
|
||||||
|
|
||||||
|
|
@ -989,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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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]}")
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
@ -878,7 +903,7 @@ 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'])}")
|
||||||
entree = {"proto": proto, "port": str(port), "role": g,
|
entree = {"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
|
# « TOUT LE MONDE » NE DOIT PAS EFFACER QUI ETAIT NOMME (2026-10-04). L'hote
|
||||||
|
|
@ -963,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"
|
||||||
|
|
@ -992,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"
|
||||||
|
|
@ -1001,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"
|
||||||
|
|
|
||||||
54
scripts/tests/test_apps_nextcloud.py
Normal file
54
scripts/tests/test_apps_nextcloud.py
Normal 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())
|
||||||
81
scripts/tests/test_conditions_site.py
Normal file
81
scripts/tests/test_conditions_site.py
Normal 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())
|
||||||
112
scripts/tests/test_genome_colis.py
Normal file
112
scripts/tests/test_genome_colis.py
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""`genome_colis.py` : le colis porte la branche ET les releases jusqu'à la forge du site.
|
||||||
|
|
||||||
|
POURQUOI (2026-10-07). Le colis ne portait que `main`. Les étiquettes de release
|
||||||
|
n'arrivaient donc jamais sur la forge du site, qui fait autorité (D-81) : `v2026.08.21` et
|
||||||
|
`v2026.10.07` y manquaient, et il a fallu les pousser à la main.
|
||||||
|
|
||||||
|
Sur de vrais dépôts git, dans un répertoire jetable (aucun réseau) :
|
||||||
|
- un poste, une forge nue, un runner cloné depuis la forge ;
|
||||||
|
- le poste fait un commit et étiquette une release ; `emballer`, puis `recevoir` sur le
|
||||||
|
runner, avec la forge pour `pushurl` ;
|
||||||
|
- la forge porte la nouvelle tête ET l'étiquette ; un dépôt sans étiquette passe aussi ;
|
||||||
|
- une étiquette déplacée sur le poste est REFUSÉE : une release ne bouge pas.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
RACINE = Path(__file__).resolve().parents[2]
|
||||||
|
COLIS = RACINE / "scripts" / "genome_colis.py"
|
||||||
|
ENV = {**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t",
|
||||||
|
"GIT_COMMITTER_EMAIL": "t@t", "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_NOSYSTEM": "1"}
|
||||||
|
|
||||||
|
ECHECS: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
def verifier(cond: bool, msg: str) -> None:
|
||||||
|
print(("OK " if cond else "ECHEC ") + msg)
|
||||||
|
if not cond:
|
||||||
|
ECHECS.append(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def git(*args: str, cwd: Path | None = None) -> str:
|
||||||
|
r = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, env=ENV)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise RuntimeError(f"git {' '.join(args)} : {r.stderr.strip()}")
|
||||||
|
return r.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def colis(*args: str) -> subprocess.CompletedProcess:
|
||||||
|
return subprocess.run([sys.executable, str(COLIS), *args], capture_output=True, text=True, env=ENV)
|
||||||
|
|
||||||
|
|
||||||
|
def monter(d: Path) -> tuple[Path, Path, Path]:
|
||||||
|
forge, poste, runner = d / "forge.git", d / "poste", d / "runner"
|
||||||
|
git("init", "-q", "--bare", "-b", "main", str(forge))
|
||||||
|
git("init", "-q", "-b", "main", str(poste))
|
||||||
|
(poste / "a").write_text("1")
|
||||||
|
git("add", "a", cwd=poste)
|
||||||
|
git("commit", "-q", "-m", "un", cwd=poste)
|
||||||
|
git("push", "-q", str(forge), "main", cwd=poste)
|
||||||
|
git("clone", "-q", str(forge), str(runner))
|
||||||
|
return forge, poste, runner
|
||||||
|
|
||||||
|
|
||||||
|
def porter(poste: Path, runner: Path, forge: Path, d: Path) -> subprocess.CompletedProcess:
|
||||||
|
paquet = d / "x.bundle"
|
||||||
|
e = colis("emballer", str(poste), str(paquet))
|
||||||
|
if e.returncode != 0:
|
||||||
|
return e
|
||||||
|
return colis("recevoir", str(runner), str(paquet), str(forge))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
d = Path(d)
|
||||||
|
forge, poste, runner = monter(d)
|
||||||
|
|
||||||
|
# Un depot sans etiquette : rien ne change pour lui.
|
||||||
|
(poste / "a").write_text("2")
|
||||||
|
git("commit", "-q", "-am", "deux", cwd=poste)
|
||||||
|
r = porter(poste, runner, forge, d)
|
||||||
|
verifier(r.returncode == 0 and git("rev-parse", "main", cwd=forge) == git("rev-parse", "HEAD", cwd=poste),
|
||||||
|
f"sans etiquette : la forge porte la tete du poste ({r.stderr.strip()[-160:]})")
|
||||||
|
|
||||||
|
# Une release annotee.
|
||||||
|
(poste / "a").write_text("3")
|
||||||
|
git("commit", "-q", "-am", "trois", cwd=poste)
|
||||||
|
git("tag", "-a", "v1", "-m", "release v1", cwd=poste)
|
||||||
|
r = porter(poste, runner, forge, d)
|
||||||
|
recu = json.loads(r.stdout) if r.returncode == 0 else {}
|
||||||
|
verifier(r.returncode == 0 and "v1" in git("tag", "-l", cwd=forge).split(),
|
||||||
|
f"la release voyage jusqu'a la forge ({r.stderr.strip()[-160:]})")
|
||||||
|
verifier(git("rev-parse", "v1", cwd=forge) == git("rev-parse", "v1", cwd=poste),
|
||||||
|
"la forge porte le MEME objet d'etiquette que le poste (annotee, pas recreee)")
|
||||||
|
verifier(recu.get("etiquettes") == ["v1"], f"le runner dit les etiquettes qu'il porte ({recu.get('etiquettes')})")
|
||||||
|
e = json.loads(colis("emballer", str(poste), str(d / "y.bundle")).stdout)
|
||||||
|
verifier(e.get("etiquettes") == ["v1"], "l'emballage dit les etiquettes du poste")
|
||||||
|
|
||||||
|
# Une release deplacee sur le poste : refusee, la forge garde l'ancienne.
|
||||||
|
ancienne = git("rev-parse", "v1", cwd=forge)
|
||||||
|
(poste / "a").write_text("4")
|
||||||
|
git("commit", "-q", "-am", "quatre", cwd=poste)
|
||||||
|
git("tag", "-f", "-a", "v1", "-m", "v1 deplacee", cwd=poste)
|
||||||
|
r = porter(poste, runner, forge, d)
|
||||||
|
verifier(r.returncode != 0 and git("rev-parse", "v1", cwd=forge) == ancienne,
|
||||||
|
f"une etiquette deplacee est refusee, la forge garde la release ({r.stderr.strip()[-120:]})")
|
||||||
|
|
||||||
|
if ECHECS:
|
||||||
|
print(f"\n{len(ECHECS)} echec(s).")
|
||||||
|
return 1
|
||||||
|
print("\nLe colis porte la branche et les releases ; une release ne bouge pas.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
225
scripts/tests/test_libelles_parefeu.py
Normal file
225
scripts/tests/test_libelles_parefeu.py
Normal 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()
|
||||||
|
|
@ -221,6 +221,21 @@ 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.
|
# LES TEMOINS (2026-10-07) : l'instantane etiquete au rasage, pas le candidat.
|
||||||
seul_courriel = rendre("setops-restaurer-courriel", [vmail_jeu])
|
seul_courriel = rendre("setops-restaurer-courriel", [vmail_jeu])
|
||||||
r = lancer("temoins", outil=seul_courriel)
|
r = lancer("temoins", outil=seul_courriel)
|
||||||
|
|
@ -327,10 +342,87 @@ exit 0
|
||||||
"un depot ordinaire garde lui aussi l'instantane d'avant rasage (c'est lui qui le chassait)")
|
"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()
|
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
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ from __future__ import annotations
|
||||||
import base64
|
import base64
|
||||||
import contextlib
|
import contextlib
|
||||||
import io
|
import io
|
||||||
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
@ -270,11 +271,81 @@ def icinga() -> None:
|
||||||
verifier(rc == 1 and "IDENTITE ca.key" in out, "une cle d'AC d'Icinga changee est un ECART")
|
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:
|
def main() -> int:
|
||||||
fichiers()
|
fichiers()
|
||||||
annuaire()
|
annuaire()
|
||||||
postgresql()
|
postgresql()
|
||||||
icinga()
|
icinga()
|
||||||
|
prometheus_loki()
|
||||||
if ECHECS:
|
if ECHECS:
|
||||||
print(f"\n{len(ECHECS)} echec(s).")
|
print(f"\n{len(ECHECS)} echec(s).")
|
||||||
return 1
|
return 1
|
||||||
|
|
|
||||||
91
scripts/tests/test_verifier_depot.py
Normal file
91
scripts/tests/test_verifier_depot.py
Normal 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())
|
||||||
107
scripts/tests/test_verrou_runner.py
Normal file
107
scripts/tests/test_verrou_runner.py
Normal 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())
|
||||||
79
verrou-runner.yml
Normal file
79
verrou-runner.yml
Normal 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
|
||||||
Loading…
Reference in a new issue