Compare commits

...

7 commits

Author SHA1 Message Date
479ec3cc7d journal : l'arret de Loki attend un envoi d'Alloy (10 s), pas la copie
Correction de (109) : la liaison des morceaux fonctionne (copie ~60 ms) ; les 10,75 s viennent de l'arret propre de Loki, qui attend un envoi d'Alloy jusqu'a son delai de 10 s. Aucune perte mesuree ; laisse tel quel.

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:04:31 -04:00
18 changed files with 900 additions and 15 deletions

View file

@ -1,5 +1,239 @@
# CHANGELOG — Set-OPS
## 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`
**94 preuves.** Les deux locataires rasés et reconstruits par le site qui les nomme, sur le

View file

@ -318,6 +318,7 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire)
python3 scripts/tests/test_raser.py
python3 scripts/tests/test_raser_resultat.py
python3 scripts/tests/test_temoins.py
python3 scripts/tests/test_genome_colis.py
python3 scripts/tests/test_adressage_derive.py
python3 scripts/tests/test_gui_intrants.py
python3 scripts/tests/test_runbooks.py

View file

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

View file

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

View file

@ -83,6 +83,29 @@ client_backup_catalogue:
# PAS DE `serveur_web_frontal` (retire le 2026-09-30) : le frontal RELAIE, il ne sert
# rien lui-meme — ses vhosts, son WAF et sa page 404 se redeploient depuis le depot.
# `/srv/web` venait de l'epoque ou il servait du statique ; ses instantanes etaient vides.
# LES METRIQUES ET LES JOURNAUX (2026-10-07). Ils repartaient de zero a chaque
# reconstruction. Ils s'ecrivent en continu : une copie A FROID (service arrete le temps
# de copier, `setops-copie-a-froid`) les pose dans le repertoire de preparation, et c'est
# cette copie que restic emporte. `vivant` dit ou vit la base, pour la restauration et les
# temoins, qui la comparent a l'instantane.
serveur_prometheus:
nom: prometheus
vivant: "{{ serveur_prometheus_donnees | default('/var/lib/prometheus/metrics2') }}"
commande: >-
/usr/local/sbin/setops-copie-a-froid
{{ serveur_prometheus_service | default('prometheus') }}
{{ serveur_prometheus_donnees | default('/var/lib/prometheus/metrics2') }}
{{ client_backup_staging }}/prometheus/metrics2 --blocs-immuables
chemins: ["{{ client_backup_staging }}/prometheus"]
serveur_loki:
nom: loki
vivant: "{{ serveur_loki_chemin | default('/var/lib/loki') }}"
commande: >-
/usr/local/sbin/setops-copie-a-froid
{{ serveur_loki_service | default('loki') }}
{{ serveur_loki_chemin | default('/var/lib/loki') }}
{{ client_backup_staging }}/loki/donnees --lier chunks --copier chunks/index
chemins: ["{{ client_backup_staging }}/loki"]
serveur_icinga:
# L'AC D'ICINGA FAIT L'IDENTITE DE SA BASE (2026-10-07). Icinga 2 derive l'environnement
# d'Icinga DB de la cle publique de son AC, et chaque identifiant d'hote, de service et

View file

@ -0,0 +1,84 @@
#!/bin/bash
# Géré par Set-OPS (rôle client_backup). Ne pas éditer à la main.
#
# setops-copie-a-froid SERVICE SOURCE DESTINATION [--blocs-immuables] [--lier REL]... [--copier REL]...
#
# Copie COHERENTE d'une base qui ecrit en continu (Prometheus, Loki) : le service est
# arrete le temps de la copie, puis redemarre — QUOI QU'IL ARRIVE a la copie, et seulement
# s'il tournait avant. La destination n'est remplacee qu'une fois la copie complete.
#
# POURQUOI A FROID (decision de l'exploitant, 2026-10-07 : « on ne prend pas de risque »).
# Copier a chaud un WAL qu'on ecrit, c'est sauvegarder un fichier a moitie ecrit. L'autre
# voie — ne garder que les blocs fermes — perdait jusqu'aux deux dernieres heures de
# metriques a chaque reconstruction. Mesure au site : 2,9 Go de Prometheus se lisent en
# 4,2 s, son WAL seul en 0,3 s ; Loki entier en 2 s.
#
# `--blocs-immuables` : les sous-dossiers nommes par un ULID (26 caracteres, `01…`) sont
# les blocs de Prometheus, que rien ne reecrit apres leur fermeture. On les recopie par
# LIENS DURS — instantane, sans place — et le reste (WAL, `chunks_head`) par une vraie
# copie. Si la destination est sur un autre systeme de fichiers, on copie tout.
#
# `--lier REL` lie tout un sous-dossier ; `--copier REL` y copie vraiment un sous-dossier qui,
# lui, bouge (2026-10-07). Pour Loki : `--lier chunks --copier chunks/index`. Ses morceaux sont
# adresses par leur contenu (`debut:fin:somme`, decode sur site-mon-01) et ne bougent jamais ;
# mais `chunks/index/` loge aussi `delete_requests.gz`, la base du compacteur, mesuree
# reecrite trois semaines apres sa naissance. La lier, c'etait laisser la sauvegarde changer
# avec elle. Copier 6 550 morceaux arretait Loki 13 s ; les lier prend 40 ms.
set -euo pipefail
service="${1:?service}"
source="${2:?source}"
dest="${3:?destination}"
shift 3
blocs="" lier=() copier=()
while (( $# )); do
case "$1" in
--blocs-immuables) blocs=1; shift ;;
--lier) lier+=("$2"); shift 2 ;;
--copier) copier+=("$2"); shift 2 ;;
*) echo "copie a froid : option inconnue : $1" >&2; exit 2 ;;
esac
done
[[ -d "${source}" ]] || { echo "copie a froid : ${source} n'existe pas — rien a copier." >&2; exit 0; }
tmp="${dest}.en-cours"
rm -rf "${tmp}"
mkdir -p "$(dirname "${dest}")"
actif=0
if systemctl is-active --quiet "${service}"; then actif=1; fi
relancer() { if (( actif )); then systemctl start "${service}"; fi; }
debut=$(date +%s%N)
if (( actif )); then systemctl stop "${service}"; fi
trap relancer EXIT
lie() { cp -al "$1" "$2" 2>/dev/null || { rm -rf "$2"; cp -a "$1" "$2"; }; }
mkdir -p "${tmp}"
for e in "${source}"/* "${source}"/.[!.]*; do
[[ -e "${e}" ]] || continue
n="$(basename "${e}")"
if [[ -n "${blocs}" && -d "${e}" && ${#n} -eq 26 && "${n}" == 01* ]] \
|| [[ " ${lier[*]:-} " == *" ${n} "* ]]; then
lie "${e}" "${tmp}/${n}"
else
cp -a "${e}" "${tmp}/${n}"
fi
done
# Ce qui bouge, a l'interieur d'un dossier lie : remplace par une vraie copie.
for c in "${copier[@]:+${copier[@]}}"; do
if [[ -e "${source}/${c}" ]]; then
rm -rf "${tmp:?}/${c}"
mkdir -p "$(dirname "${tmp}/${c}")"
cp -a "${source}/${c}" "${tmp}/${c}"
fi
done
trap - EXIT
relancer
fin=$(date +%s%N)
rm -rf "${dest}"
mv "${tmp}" "${dest}"
echo "copie a froid de ${source} : ${service} $( (( actif )) && echo "arrete (jusqu a la relance, rejeu du WAL non compris) $(( (fin - debut) / 1000000 )) ms" || echo "n'etait pas actif")"

View file

@ -19,7 +19,8 @@ VERDICTS, PAR JEU :
clés et certificats de l'AC, AC d'Icinga et son environnement, clé DKIM,
`instanceid` de Nextcloud, mot de passe d'une entrée de l'annuaire ; une
base dont plus aucune ligne d'avant ne subsiste ; une ligne d'historique
perdue. 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.
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 collections
import hashlib
import json
import os
import re
import subprocess
@ -174,6 +176,75 @@ def instance_id(r: Rapport, avant: Path, vivant: Path) -> None:
r.ecarts.append(f"IDENTITE instanceid : {a} -> {v or '(absent)'}")
# --- Les bases copiees a froid : Prometheus et Loki (2026-10-07) ----------------------
#
# Leurs FICHIERS ne se comparent pas : Prometheus fusionne ses blocs, Loki compacte son
# index, et la retention (15 j, 31 j) en retire chaque jour. On compare ce qui compte.
def blocs_prometheus(racine: Path) -> list[tuple[int, int]]:
out = []
for meta in racine.glob("01*/meta.json") if racine.is_dir() else []:
try:
m = json.loads(meta.read_text())
out.append((int(m["minTime"]), int(m["maxTime"])))
except (ValueError, KeyError):
continue
return sorted(out)
def segments_wal(racine: Path) -> list[str]:
return sorted(p.name for p in (racine / "wal").glob("[0-9]*")) if (racine / "wal").is_dir() else []
def jeu_prometheus(avant: Path, vivant: Path) -> Rapport:
"""Les métriques vivantes couvrent-elles encore l'avant-rasage ?
Le repère : le DÉBUT du bloc le plus récent de l'instantané — quelques heures avant le
rasage. Un bloc vivant doit couvrir cet instant. La rétention ne l'a pas retiré (il a
moins de 15 jours) ; une base qui n'a pas été restaurée ne peut pas le couvrir : elle
est née après la reconstruction. Une base de moins de deux heures n'a pas encore de
bloc : son WAL fait alors le repère, et doit se retrouver dans la base vivante, ou y
avoir été fermé en bloc.
"""
r = Rapport("prometheus")
a, v = blocs_prometheus(avant), blocs_prometheus(vivant)
r.compte = len(a)
if a:
t = max(debut for debut, _ in a)
if not any(debut <= t < fin for debut, fin in v):
r.ecarts.append(f"aucun bloc vivant ne couvre {t} (debut du dernier bloc d'avant) : "
f"metriques d'avant non restaurees")
if v and min(d for d, _ in v) > min(d for d, _ in a):
r.changes.append(f"plus ancienne donnee : {min(d for d, _ in a)} -> {min(d for d, _ in v)} (retention)")
else:
wal = segments_wal(avant)
if wal and not v and wal[0] not in segments_wal(vivant):
r.ecarts.append(f"le WAL d'avant ({wal[0]}...) n'est pas dans la base vivante, et elle n'a "
f"aucun bloc : metriques d'avant non restaurees")
if len(v) != len(a):
r.changes.append(f"{len(a)} bloc(s) avant, {len(v)} maintenant (fusions, retention, nouveaux)")
return r
def jeu_loki(avant: Path, vivant: Path) -> Rapport:
"""Des morceaux de journal d'avant subsistent-ils ? Aucun : non restaurés.
La rétention (31 j) en retire chaque jour : une perte PARTIELLE est listée. Une perte
TOTALE, elle, ne vient que d'une base qui n'a pas été remise.
"""
r = Rapport("loki")
a = set(arbre(avant / "chunks"))
v = set(arbre(vivant / "chunks"))
r.compte = len(a)
if a and not (a & v):
r.ecarts.append(f"aucun des {len(a)} morceau(x) de journal d'avant ne subsiste : journaux non restaures")
elif a - v:
r.retires.append(f"{len(a - v)} morceau(x) d'avant retire(s) (retention ?)")
if v - a:
r.nouveaux.append(f"{len(v - a)} morceau(x) nouveau(x)")
return r
# --- L'annuaire ----------------------------------------------------------------------
def entrees_ldif(texte: str) -> dict[str, dict[str, list[str]]]:
@ -317,6 +388,8 @@ def main(argv: list[str] | None = None, interroger=psql) -> int:
ap.add_argument("--heure", default="")
ap.add_argument("--hote", default="")
ap.add_argument("--jeu", action="append", default=[], help="NOM=CHEMIN[:CHEMIN...]")
ap.add_argument("--vivant", action="append", default=[],
help="NOM=CHEMIN : ou vit la base d'un jeu copie a froid")
ap.add_argument("--ldif", default="", help="chemin du LDIF dans l'instantané")
ap.add_argument("--ldif-vivant", type=Path, default=None)
ap.add_argument("--dump", default="", help="chemin du pg_dumpall dans l'instantané")
@ -327,9 +400,17 @@ def main(argv: list[str] | None = None, interroger=psql) -> int:
print(" SANS OBJET : ce noeud ne detient aucun etat.")
return 0
ecart = False
vivants = dict(v.partition("=")[::2] for v in a.vivant)
for spec in a.jeu:
nom, _, chemins = spec.partition("=")
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)"
elif nom == "postgresql":
r, unite = jeu_postgresql(a.avant / a.dump.lstrip("/"), interroger), "table(s)"

View file

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

View file

@ -28,6 +28,8 @@
# choisir [--instantane ID] (pour Ansible) JSON de l'instantane candidat
# fichiers [opts] CHEMIN... remettre des repertoires (rsync --delete)
# --proprietaire U:G --remplacer --vers DOSSIER (repetition, rien n'est touche)
# --depuis CHEMIN_DANS_L_INSTANTANE (un seul CHEMIN) : ce qui a ete sauvegarde
# ailleurs que la ou ca vit (copie a froid)
# annuaire [opts] BASE_DN remplacer la base LDAP (slapadd)
# --essai (slapadd -u a blanc, rien n'est touche)
# base [opts] NOM rejouer une base depuis le pg_dumpall
@ -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.
declare -A CHEMINS_JEU=({% for j in client_backup_jobs %} ["{{ j.nom }}"]="{{ j.chemins | join(':') }}"{% endfor %} )
ETIQUETTE_AVANT_RASER="{{ client_backup_etiquette_avant_raser }}"
# Ou vit la base des jeux copies a froid (Prometheus, Loki) : leur instantane porte la
# copie, pas la base. Les temoins comparent l'une a l'autre.
declare -A VIVANT_JEU=({% for j in client_backup_jobs if j.vivant is defined %} ["{{ j.nom }}"]="{{ j.vivant }}"{% endfor %} )
dire() { printf '%s\n' "$*" >&2; }
mourir() { dire "setops-restaurer: $*"; exit 1; }
@ -154,22 +159,28 @@ vide() { # vrai si le chemin est absent, ou un repertoire sans contenu
}
cmd_fichiers() {
local proprio="" remplacer=0 vers="" chemins=() src dest n
local proprio="" remplacer=0 vers="" depuis="" chemins=() src dest n
while (( $# )); do
case "$1" in
--proprietaire) proprio="$2"; shift 2 ;;
--remplacer) remplacer=1; shift ;;
--vers) vers="$2"; shift 2 ;;
--depuis) depuis="$2"; shift 2 ;;
--instantane) INSTANTANE="$2"; shift 2 ;;
*) chemins+=("$1"); shift ;;
esac
done
[[ -n "${chemins[*]:-}" ]] || mourir "fichiers : aucun chemin"
# LA COPIE A FROID (2026-10-07) : l'instantane porte la copie posee dans le repertoire de
# preparation, pas la base. `--depuis` dit ou la prendre ; le CHEMIN, ou la remettre.
# (Un second element, plutot qu'une longueur : la sequence accolade-diese ouvre un
# commentaire Jinja dans ce gabarit.)
if [[ -n "${depuis}" && -n "${chemins[1]:-}" ]]; then mourir "fichiers --depuis : un seul chemin"; fi
resoudre_instantane
if [[ -z "${INSTANTANE}" ]]; then echo "RIEN : aucune incarnation precedente."; return 0; fi
extraire "${chemins[@]}"
if [[ -n "${depuis}" ]]; then extraire "${depuis}"; else extraire "${chemins[@]}"; fi
for c in "${chemins[@]}"; do
src="${TMP}${c}"
src="${TMP}${depuis:-${c}}"
dest="${vers}${c}"
if [[ ! -e "${src}" ]]; then echo "ABSENT de ${INSTANTANE} : ${c}"; continue; fi
if ! vide "${dest}"; then
@ -358,6 +369,7 @@ cmd_temoins() {
IFS=: read -r -a morceaux <<< "${CHEMINS_JEU[${j}]}"
chemins+=("${morceaux[@]}")
args+=(--jeu "${j}=${CHEMINS_JEU[${j}]}")
if [[ -n "${VIVANT_JEU[${j}]:-}" ]]; then args+=(--vivant "${j}=${VIVANT_JEU[${j}]}"); fi
done
extraire "${chemins[@]}"
if [[ -s "${TMP}${LDIF}" ]]; then

View file

@ -21,3 +21,5 @@ client_backup_groupes_etat:
- serveur_rspamd
- serveur_web_dorsal
- serveur_icinga
- serveur_prometheus
- serveur_loki

View file

@ -195,6 +195,56 @@
group: loki
mode: "0750"
# --- LA BASE D'UNE INCARNATION PRECEDENTE (2026-10-07) --------------------------------
#
# Les journaux repartaient de zero a chaque reconstruction. client_backup en garde une
# copie a froid (jeu `loki`) ; on la remet ICI, avant que Loki ne demarre sur sa
# configuration : une base sans aucun morceau de journal est une base neuve. Service
# arrete pendant la remise ; « Activer et demarrer Loki » plus bas le relance.
- name: La base de Loki a-t-elle deja des morceaux de journal ?
ansible.builtin.find:
paths: "{{ serveur_loki_chemin }}/chunks"
recurse: true
register: serveur_loki_morceaux
- name: Restauration — la base d'une incarnation precedente ?
ansible.builtin.include_role:
name: client_backup
tasks_from: restaurer.yml
vars:
client_backup_restaurer_jeu: serveur_loki
client_backup_restaurer_vierge: "{{ serveur_loki_morceaux.matched == 0 }}"
- name: Restauration — remettre la base, service arrete
when: client_backup_restauration.etat == 'a_restaurer'
block:
- name: Restauration — arreter Loki
ansible.builtin.systemd:
name: "{{ serveur_loki_service }}"
state: stopped
- name: Restauration — remettre la copie a froid a sa place
changed_when: true
ansible.builtin.command:
argv:
- /usr/local/sbin/setops-restaurer
- fichiers
- --instantane
- "{{ client_backup_restauration.instantane }}"
- --depuis
- "{{ client_backup_restauration.chemins[0] }}/donnees"
- --remplacer
- --proprietaire
- loki:loki
- "{{ serveur_loki_chemin }}"
- name: Restauration — acter la base remise
ansible.builtin.include_role:
name: client_backup
tasks_from: acter.yml
vars:
client_backup_acter_etat: restaure
# --- TLS : synchroniser le cert step_ca AVANT d'activer http_tls_config ---
- name: TLS — repertoire des certificats (loki)
ansible.builtin.file:

View file

@ -4,6 +4,9 @@ serveur_prometheus_paquets:
serveur_prometheus_service: "prometheus"
serveur_prometheus_config: "/etc/prometheus/prometheus.yml"
# Le dossier de la base (defaut du paquet Debian, que `ARGS` ne change pas). Lu aussi par
# client_backup : la copie a froid et la restauration le visent.
serveur_prometheus_donnees: "/var/lib/prometheus/metrics2"
serveur_prometheus_intervalle: "15s"
serveur_prometheus_retention: "15d"

View file

@ -6,6 +6,58 @@
update_cache: true
cache_valid_time: 3600
# --- LA BASE D'UNE INCARNATION PRECEDENTE (2026-10-07) --------------------------------
#
# Les metriques repartaient de zero a chaque reconstruction. client_backup en garde une
# copie a froid (jeu `prometheus`) ; on la remet ICI, juste apres l'installation : le paquet
# demarre Prometheus aussitot, mais sa base n'a encore aucun bloc (le premier se ferme au
# bout de deux heures). Service arrete pendant la remise ; la tache « Activer et demarrer »
# plus bas le relance.
- name: La base de Prometheus a-t-elle deja des blocs ?
ansible.builtin.find:
paths: "{{ serveur_prometheus_donnees }}"
file_type: directory
patterns: "01*"
register: serveur_prometheus_blocs
- name: Restauration — la base d'une incarnation precedente ?
ansible.builtin.include_role:
name: client_backup
tasks_from: restaurer.yml
vars:
client_backup_restaurer_jeu: serveur_prometheus
client_backup_restaurer_vierge: "{{ serveur_prometheus_blocs.matched == 0 }}"
- name: Restauration — remettre la base, service arrete
when: client_backup_restauration.etat == 'a_restaurer'
block:
- name: Restauration — arreter Prometheus
ansible.builtin.systemd:
name: "{{ serveur_prometheus_service }}"
state: stopped
- name: Restauration — remettre la copie a froid a sa place
changed_when: true
ansible.builtin.command:
argv:
- /usr/local/sbin/setops-restaurer
- fichiers
- --instantane
- "{{ client_backup_restauration.instantane }}"
- --depuis
- "{{ client_backup_restauration.chemins[0] }}/metrics2"
- --remplacer
- --proprietaire
- prometheus:prometheus
- "{{ serveur_prometheus_donnees }}"
- name: Restauration — acter la base remise
ansible.builtin.include_role:
name: client_backup
tasks_from: acter.yml
vars:
client_backup_acter_etat: restaure
# --- LES CIBLES QUE LES ROLES DECLARENT (2026-09-14) ----------------------------------
#
# `serveur_prometheus_cibles_supplementaires` existait depuis longtemps — une liste libre,

View file

@ -28,6 +28,13 @@ DEUX GESTES, DEUX COTES :
genome_colis.py etat <depot>
Des deux cotes. Ce que cette copie porte, pour le confronter a la forge.
LES ETIQUETTES VOYAGENT AVEC LA BRANCHE (2026-10-07). Le colis ne portait que `main` : les
releases n'arrivaient jamais sur la forge du site, qui fait pourtant autorite (D-81).
`v2026.08.21` et `v2026.10.07` y manquaient, et il a fallu les pousser a la main. Le colis
emporte desormais toutes les etiquettes du poste ; le runner les recoit et les pousse. Une
etiquette qui existe deja SOUS UN AUTRE OBJET est refusee, des deux cotes : une release ne
bouge pas.
`--ff-only` est le garde-fou. Si les deux histoires ont diverge, on s'arrete plutot que de
fabriquer une fusion que personne n'a decidee — et le message de git nomme alors exactement
ce qu'il faut reconcilier.
@ -55,13 +62,18 @@ def _git(depot: Path, *args: str, env: dict | None = None) -> str:
return (r.stdout or "").strip()
def etiquettes(depot: Path) -> list[str]:
return sorted(e for e in _git(depot, "tag", "-l").splitlines() if e)
def emballer(depot: Path, colis: Path, branche: str) -> int:
if not (depot / ".git").exists():
raise SystemExit(f"{depot} n'est pas un depot git — rien a emballer.")
colis.parent.mkdir(parents=True, exist_ok=True)
_git(depot, "bundle", "create", str(colis), branche)
_git(depot, "bundle", "create", str(colis), branche, "--tags")
print(json.dumps({"depot": str(depot), "colis": str(colis),
"tete": _git(depot, "log", "-1", "--format=%H")}))
"tete": _git(depot, "log", "-1", "--format=%H"),
"etiquettes": etiquettes(depot)}))
return 0
@ -101,6 +113,10 @@ def recevoir(depot: Path, colis: Path, pushurl: str, cle: str | None,
f"refs/heads/{branche}:refs/remotes/colis/{branche}")
_git(depot, "merge", "--ff-only", f"refs/remotes/colis/{branche}")
apres = _git(depot, "log", "-1", "--format=%H")
# Sans `--force` : une etiquette deja presente sous un autre objet fait echouer la
# recuperation (« would clobber existing tag ») — c'est voulu.
if any("refs/tags/" in l for l in _git(depot, "bundle", "list-heads", str(colis)).splitlines()):
_git(depot, "fetch", str(colis), "refs/tags/*:refs/tags/*")
env = None
if cle:
@ -114,14 +130,15 @@ def recevoir(depot: Path, colis: Path, pushurl: str, cle: str | None,
# seule l'ECRITURE s'authentifie, et seulement le temps de cette commande.
r = subprocess.run(
["git", "-C", str(depot), "-c", f"remote.origin.pushurl={pushurl}",
"push", "origin", branche],
"push", "origin", branche, "refs/tags/*:refs/tags/*"],
capture_output=True, text=True, env=env)
sortie = ((r.stdout or "") + (r.stderr or "")).strip()
if r.returncode != 0:
raise SystemExit(f"la poussee a echoue :\n {sortie}")
print(json.dumps({"avant": avant, "apres": apres,
"pousse": f"{branche} -> {branche}" in sortie, "sortie": sortie[:400]}))
"pousse": f"{branche} -> {branche}" in sortie,
"etiquettes": etiquettes(depot), "sortie": sortie[:400]}))
return 0

View file

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

View file

@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""`genome_colis.py` : le colis porte la branche ET les releases jusqu'à la forge du site.
POURQUOI (2026-10-07). Le colis ne portait que `main`. Les étiquettes de release
n'arrivaient donc jamais sur la forge du site, qui fait autorité (D-81) : `v2026.08.21` et
`v2026.10.07` y manquaient, et il a fallu les pousser à la main.
Sur de vrais dépôts git, dans un répertoire jetable (aucun réseau) :
- un poste, une forge nue, un runner cloné depuis la forge ;
- le poste fait un commit et étiquette une release ; `emballer`, puis `recevoir` sur le
runner, avec la forge pour `pushurl` ;
- la forge porte la nouvelle tête ET l'étiquette ; un dépôt sans étiquette passe aussi ;
- une étiquette déplacée sur le poste est REFUSÉE : une release ne bouge pas.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
RACINE = Path(__file__).resolve().parents[2]
COLIS = RACINE / "scripts" / "genome_colis.py"
ENV = {**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@t", "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_NOSYSTEM": "1"}
ECHECS: list[str] = []
def verifier(cond: bool, msg: str) -> None:
print(("OK " if cond else "ECHEC ") + msg)
if not cond:
ECHECS.append(msg)
def git(*args: str, cwd: Path | None = None) -> str:
r = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, env=ENV)
if r.returncode != 0:
raise RuntimeError(f"git {' '.join(args)} : {r.stderr.strip()}")
return r.stdout.strip()
def colis(*args: str) -> subprocess.CompletedProcess:
return subprocess.run([sys.executable, str(COLIS), *args], capture_output=True, text=True, env=ENV)
def monter(d: Path) -> tuple[Path, Path, Path]:
forge, poste, runner = d / "forge.git", d / "poste", d / "runner"
git("init", "-q", "--bare", "-b", "main", str(forge))
git("init", "-q", "-b", "main", str(poste))
(poste / "a").write_text("1")
git("add", "a", cwd=poste)
git("commit", "-q", "-m", "un", cwd=poste)
git("push", "-q", str(forge), "main", cwd=poste)
git("clone", "-q", str(forge), str(runner))
return forge, poste, runner
def porter(poste: Path, runner: Path, forge: Path, d: Path) -> subprocess.CompletedProcess:
paquet = d / "x.bundle"
e = colis("emballer", str(poste), str(paquet))
if e.returncode != 0:
return e
return colis("recevoir", str(runner), str(paquet), str(forge))
def main() -> int:
with tempfile.TemporaryDirectory() as d:
d = Path(d)
forge, poste, runner = monter(d)
# Un depot sans etiquette : rien ne change pour lui.
(poste / "a").write_text("2")
git("commit", "-q", "-am", "deux", cwd=poste)
r = porter(poste, runner, forge, d)
verifier(r.returncode == 0 and git("rev-parse", "main", cwd=forge) == git("rev-parse", "HEAD", cwd=poste),
f"sans etiquette : la forge porte la tete du poste ({r.stderr.strip()[-160:]})")
# Une release annotee.
(poste / "a").write_text("3")
git("commit", "-q", "-am", "trois", cwd=poste)
git("tag", "-a", "v1", "-m", "release v1", cwd=poste)
r = porter(poste, runner, forge, d)
recu = json.loads(r.stdout) if r.returncode == 0 else {}
verifier(r.returncode == 0 and "v1" in git("tag", "-l", cwd=forge).split(),
f"la release voyage jusqu'a la forge ({r.stderr.strip()[-160:]})")
verifier(git("rev-parse", "v1", cwd=forge) == git("rev-parse", "v1", cwd=poste),
"la forge porte le MEME objet d'etiquette que le poste (annotee, pas recreee)")
verifier(recu.get("etiquettes") == ["v1"], f"le runner dit les etiquettes qu'il porte ({recu.get('etiquettes')})")
e = json.loads(colis("emballer", str(poste), str(d / "y.bundle")).stdout)
verifier(e.get("etiquettes") == ["v1"], "l'emballage dit les etiquettes du poste")
# Une release deplacee sur le poste : refusee, la forge garde l'ancienne.
ancienne = git("rev-parse", "v1", cwd=forge)
(poste / "a").write_text("4")
git("commit", "-q", "-am", "quatre", cwd=poste)
git("tag", "-f", "-a", "v1", "-m", "v1 deplacee", cwd=poste)
r = porter(poste, runner, forge, d)
verifier(r.returncode != 0 and git("rev-parse", "v1", cwd=forge) == ancienne,
f"une etiquette deplacee est refusee, la forge garde la release ({r.stderr.strip()[-120:]})")
if ECHECS:
print(f"\n{len(ECHECS)} echec(s).")
return 1
print("\nLe colis porte la branche et les releases ; une release ne bouge pas.")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -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()})")
verifier(any((d / "mis-de-cote").rglob("x")), "l'etat ecrase a ete mis de cote")
# LA COPIE A FROID (2026-10-07) : l'instantane porte la copie posee dans la
# preparation ; `--depuis` la remet a sa place vivante, qui porte un autre nom.
copie = fixt / "avant01" / str(staging).lstrip("/") / "prometheus" / "metrics2" / ("01" + "B" * 24)
copie.mkdir(parents=True)
(copie / "meta.json").write_text("{}")
vivante = d / "prometheus-vivant"
(vivante / "wal").mkdir(parents=True)
(vivante / "wal" / "00000000").write_text("neuf")
r = lancer("fichiers", "--depuis", str(staging / "prometheus" / "metrics2"), "--remplacer", str(vivante))
verifier(r.returncode == 0 and (vivante / ("01" + "B" * 24) / "meta.json").exists()
and not (vivante / "wal").exists(),
f"--depuis remet la copie a froid a sa place vivante ({r.stdout.strip() or r.stderr.strip()})")
r = lancer("fichiers", "--depuis", str(staging), str(d / "a"), str(d / "b"))
verifier(r.returncode != 0, "--depuis refuse plusieurs destinations")
# LES TEMOINS (2026-10-07) : l'instantane etiquete au rasage, pas le candidat.
seul_courriel = rendre("setops-restaurer-courriel", [vmail_jeu])
r = lancer("temoins", outil=seul_courriel)
@ -327,10 +342,87 @@ exit 0
"un depot ordinaire garde lui aussi l'instantane d'avant rasage (c'est lui qui le chassait)")
# --- 4. La copie a froid (2026-10-07) -----------------------------------------------------
def copie_a_froid() -> None:
"""Le service est arrete le temps de copier, et relance QUOI QU'IL ARRIVE ; les blocs
immuables sont lies, le reste copie ; un service inactif n'est ni arrete ni relance."""
outil = RACINE / "roles/client_backup/files/setops-copie-a-froid.sh"
with tempfile.TemporaryDirectory() as d:
d = Path(d)
bin_, journal = d / "bin", d / "systemctl.log"
bin_.mkdir()
executable(bin_ / "systemctl", f"""#!/bin/bash
echo "$*" >> "{journal}"
[[ "$1" == is-active ]] && exit "${{SETOPS_TEST_ACTIF:-0}}"
exit 0
""")
# `cp` qui echoue sur le WAL quand on le demande : une copie ratee en plein milieu.
executable(bin_ / "cp", '#!/bin/bash\n[[ -n "${SETOPS_TEST_CP_ECHOUE:-}" && "$*" == *wal* ]] && exit 1\nexec /bin/cp "$@"\n')
source, dest = d / "metrics2", d / "prep" / "metrics2"
bloc = source / ("01" + "C" * 24)
bloc.mkdir(parents=True)
(bloc / "index").write_text("bloc")
(source / "wal").mkdir()
(source / "wal" / "00000001").write_text("wal")
env = {**os.environ, "PATH": f"{bin_}:{os.environ['PATH']}"}
def lancer(**extra: str) -> subprocess.CompletedProcess:
journal.unlink(missing_ok=True)
return subprocess.run(["bash", str(outil), "prometheus", str(source), str(dest), "--blocs-immuables"],
env={**env, **extra}, capture_output=True, text=True)
r = lancer()
appels = journal.read_text().split("\n") if journal.exists() else []
verifier(r.returncode == 0 and appels[:3] == ["is-active --quiet prometheus", "stop prometheus", "start prometheus"],
f"arrete, copie, relance ({appels[:3]}, {r.stderr.strip()[-120:]})")
verifier((dest / bloc.name / "index").stat().st_ino == (bloc / "index").stat().st_ino,
"un bloc immuable est lie (meme inode), pas recopie")
verifier((dest / "wal" / "00000001").stat().st_ino != (source / "wal" / "00000001").stat().st_ino
and (dest / "wal" / "00000001").read_text() == "wal",
"le WAL est vraiment copie (un autre inode)")
verifier("arrete" in r.stdout and " ms" in r.stdout, f"la duree d'arret est dite ({r.stdout.strip()})")
(dest / "temoin").write_text("copie precedente")
r = lancer(SETOPS_TEST_CP_ECHOUE="1")
appels = journal.read_text().split("\n") if journal.exists() else []
verifier(r.returncode != 0 and "start prometheus" in appels,
f"une copie ratee : le service est QUAND MEME relance ({appels})")
verifier((dest / "temoin").exists(), "une copie ratee ne remplace pas la copie precedente")
r = lancer(SETOPS_TEST_ACTIF="3")
appels = journal.read_text().split("\n") if journal.exists() else []
verifier(r.returncode == 0 and "stop prometheus" not in appels and "start prometheus" not in appels,
"un service inactif n'est ni arrete ni relance")
# LOKI (2026-10-07) : les morceaux sont lies, `chunks/index/` copie. Il loge la base
# du compacteur (`delete_requests.gz`), qui se reecrit : liee, la sauvegarde
# changerait avec elle.
loki, prep = d / "loki", d / "prep" / "donnees"
(loki / "chunks" / "fake").mkdir(parents=True)
(loki / "chunks" / "fake" / "MWEw").write_text("morceau")
(loki / "chunks" / "index" / "delete_requests").mkdir(parents=True)
(loki / "chunks" / "index" / "delete_requests" / "delete_requests.gz").write_text("v1")
(loki / "wal").mkdir()
(loki / "wal" / "00000001").write_text("wal")
r = subprocess.run(["bash", str(outil), "loki", str(loki), str(prep), "--lier", "chunks",
"--copier", "chunks/index"], env=env, capture_output=True, text=True)
verifier(r.returncode == 0 and (prep / "chunks" / "fake" / "MWEw").stat().st_ino
== (loki / "chunks" / "fake" / "MWEw").stat().st_ino,
f"Loki : un morceau est lie (meme inode) ({r.stderr.strip()[-120:]})")
base = loki / "chunks" / "index" / "delete_requests" / "delete_requests.gz"
copie = prep / "chunks" / "index" / "delete_requests" / "delete_requests.gz"
verifier(copie.exists() and copie.stat().st_ino != base.stat().st_ino,
"Loki : chunks/index est vraiment copie (un autre inode)")
base.write_text("v2") # le compacteur reecrit sa base sur place
verifier(copie.read_text() == "v1", "une reecriture apres la copie ne change pas la sauvegarde")
def main() -> int:
couverture()
outil()
sauvegarde()
copie_a_froid()
if ECHECS:
print(f"\n{len(ECHECS)} echec(s).")
return 1

View file

@ -16,6 +16,7 @@ from __future__ import annotations
import base64
import contextlib
import io
import json
import shutil
import sys
import tempfile
@ -270,11 +271,78 @@ def icinga() -> None:
verifier(rc == 1 and "IDENTITE ca.key" in out, "une cle d'AC d'Icinga changee est un ECART")
def bloc(racine: Path, nom: str, debut: int, fin: int) -> None:
ecrire(racine / nom / "meta.json", json.dumps({"minTime": debut, "maxTime": fin}))
ecrire(racine / nom / "chunks" / "000001", nom)
def prometheus_loki() -> None:
"""Prometheus fusionne ses blocs et la retention en retire : on juge la COUVERTURE.
Loki : aucun morceau d'avant = non restaure ; une perte partielle = retention."""
with tempfile.TemporaryDirectory() as d:
d = Path(d)
staging = "/var/backups/setops/prometheus"
avant = d / "avant" / staging.lstrip("/") / "metrics2"
vivant = d / "vivant"
U = "01" + "A" * 24
bloc(avant, U[:-1] + "1", 1000, 2000)
bloc(avant, U[:-1] + "2", 2000, 3000) # le dernier bloc d'avant commence a 2000
argv = ["--avant", str(d / "avant"), "--instantane", "x", "--jeu", f"prometheus={staging}",
"--vivant", f"prometheus={vivant}"]
bloc(vivant, U[:-1] + "9", 1000, 3000) # restaure, puis fusionne en un bloc
bloc(vivant, U[:-1] + "8", 3000, 4000)
rc, out = lancer(argv)
verifier(rc == 0, f"blocs restaures puis fusionnes : sans ecart ({out.strip()[-140:]})")
shutil.rmtree(vivant)
bloc(vivant, U[:-1] + "7", 9000, 9500) # une base nee apres la reconstruction
rc, out = lancer(argv)
verifier(rc == 1 and "non restaurees" in out, "une base de Prometheus non restauree est un ECART")
shutil.rmtree(vivant)
bloc(vivant, U[:-1] + "6", 2000, 4000) # la retention a retire le plus ancien
rc, out = lancer(argv)
verifier(rc == 0 and "retention" in out, "la retention qui retire le plus ancien bloc : dite, sans ecart")
# Une base de moins de deux heures : pas de bloc, le WAL fait le repere.
shutil.rmtree(avant)
shutil.rmtree(vivant)
ecrire(avant / "wal" / "00000003", "w")
ecrire(vivant / "wal" / "00000003", "w")
ecrire(vivant / "wal" / "00000004", "w")
rc, out = lancer(argv)
verifier(rc == 0, "sans bloc, le WAL d'avant retrouve dans la base vivante : sans ecart")
shutil.rmtree(vivant)
ecrire(vivant / "wal" / "00000000", "w")
rc, out = lancer(argv)
verifier(rc == 1 and "WAL d'avant" in out, "sans bloc, un WAL neuf a la place de celui d'avant : ECART")
staging = "/var/backups/setops/loki"
avant = d / "avant" / staging.lstrip("/") / "donnees"
vivant = d / "loki"
for racine in (avant, vivant):
ecrire(racine / "chunks" / "fake" / "a", "1")
ecrire(racine / "chunks" / "fake" / "b", "2")
ecrire(vivant / "chunks" / "fake" / "c", "3")
argv = ["--avant", str(d / "avant"), "--instantane", "x", "--jeu", f"loki={staging}",
"--vivant", f"loki={vivant}"]
rc, out = lancer(argv)
verifier(rc == 0 and "1 morceau(x) nouveau(x)" in out, "Loki restaure, des journaux arrives depuis : sans ecart")
(vivant / "chunks" / "fake" / "a").unlink()
rc, out = lancer(argv)
verifier(rc == 0 and "retire(s)" in out, "un morceau d'avant retire (retention) : dit, sans ecart")
(vivant / "chunks" / "fake" / "b").unlink()
rc, out = lancer(argv)
verifier(rc == 1 and "journaux non restaures" in out, "aucun morceau d'avant : Loki non restaure, ECART")
def main() -> int:
fichiers()
annuaire()
postgresql()
icinga()
prometheus_loki()
if ECHECS:
print(f"\n{len(ECHECS)} echec(s).")
return 1