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>
This commit is contained in:
Daniel Allaire 2026-10-07 21:57:04 -04:00
parent 6141bc4b2f
commit 4660a8892a
12 changed files with 491 additions and 5 deletions

View file

@ -1,5 +1,56 @@
# CHANGELOG — Set-OPS
## 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**.
**Pas encore éprouvé** : rien n'est déployé. Le trou de collecte réel (arrêt et rejeu du
WAL) se mesurera au premier dépôt.
**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`,

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
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,62 @@
#!/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]
#
# 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.
set -euo pipefail
service="${1:?service}"
source="${2:?source}"
dest="${3:?destination}"
blocs="${4:-}"
[[ -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
if [[ "${blocs}" == "--blocs-immuables" ]]; then
mkdir -p "${tmp}"
for e in "${source}"/* "${source}"/.[!.]*; do
[[ -e "${e}" ]] || continue
n="$(basename "${e}")"
if [[ -d "${e}" && ${#n} -eq 26 && "${n}" == 01* ]]; then
cp -al "${e}" "${tmp}/${n}" 2>/dev/null || cp -a "${e}" "${tmp}/${n}"
else
cp -a "${e}" "${tmp}/${n}"
fi
done
else
cp -a "${source}" "${tmp}"
fi
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

@ -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,65 @@ 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")
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