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:
parent
6141bc4b2f
commit
4660a8892a
12 changed files with 491 additions and 5 deletions
51
CHANGELOG.md
51
CHANGELOG.md
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
62
roles/client_backup/files/setops-copie-a-froid.sh
Normal file
62
roles/client_backup/files/setops-copie-a-froid.sh
Normal 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")"
|
||||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -21,3 +21,5 @@ client_backup_groupes_etat:
|
|||
- serveur_rspamd
|
||||
- serveur_web_dorsal
|
||||
- serveur_icinga
|
||||
- serveur_prometheus
|
||||
- serveur_loki
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ serveur_prometheus_paquets:
|
|||
|
||||
serveur_prometheus_service: "prometheus"
|
||||
serveur_prometheus_config: "/etc/prometheus/prometheus.yml"
|
||||
# Le dossier de la base (defaut du paquet Debian, que `ARGS` ne change pas). Lu aussi par
|
||||
# client_backup : la copie a froid et la restauration le visent.
|
||||
serveur_prometheus_donnees: "/var/lib/prometheus/metrics2"
|
||||
|
||||
serveur_prometheus_intervalle: "15s"
|
||||
serveur_prometheus_retention: "15d"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,58 @@
|
|||
update_cache: true
|
||||
cache_valid_time: 3600
|
||||
|
||||
# --- LA BASE D'UNE INCARNATION PRECEDENTE (2026-10-07) --------------------------------
|
||||
#
|
||||
# Les metriques repartaient de zero a chaque reconstruction. client_backup en garde une
|
||||
# copie a froid (jeu `prometheus`) ; on la remet ICI, juste apres l'installation : le paquet
|
||||
# demarre Prometheus aussitot, mais sa base n'a encore aucun bloc (le premier se ferme au
|
||||
# bout de deux heures). Service arrete pendant la remise ; la tache « Activer et demarrer »
|
||||
# plus bas le relance.
|
||||
- name: La base de Prometheus a-t-elle deja des blocs ?
|
||||
ansible.builtin.find:
|
||||
paths: "{{ serveur_prometheus_donnees }}"
|
||||
file_type: directory
|
||||
patterns: "01*"
|
||||
register: serveur_prometheus_blocs
|
||||
|
||||
- name: Restauration — la base d'une incarnation precedente ?
|
||||
ansible.builtin.include_role:
|
||||
name: client_backup
|
||||
tasks_from: restaurer.yml
|
||||
vars:
|
||||
client_backup_restaurer_jeu: serveur_prometheus
|
||||
client_backup_restaurer_vierge: "{{ serveur_prometheus_blocs.matched == 0 }}"
|
||||
|
||||
- name: Restauration — remettre la base, service arrete
|
||||
when: client_backup_restauration.etat == 'a_restaurer'
|
||||
block:
|
||||
- name: Restauration — arreter Prometheus
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ serveur_prometheus_service }}"
|
||||
state: stopped
|
||||
|
||||
- name: Restauration — remettre la copie a froid a sa place
|
||||
changed_when: true
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/local/sbin/setops-restaurer
|
||||
- fichiers
|
||||
- --instantane
|
||||
- "{{ client_backup_restauration.instantane }}"
|
||||
- --depuis
|
||||
- "{{ client_backup_restauration.chemins[0] }}/metrics2"
|
||||
- --remplacer
|
||||
- --proprietaire
|
||||
- prometheus:prometheus
|
||||
- "{{ serveur_prometheus_donnees }}"
|
||||
|
||||
- name: Restauration — acter la base remise
|
||||
ansible.builtin.include_role:
|
||||
name: client_backup
|
||||
tasks_from: acter.yml
|
||||
vars:
|
||||
client_backup_acter_etat: restaure
|
||||
|
||||
# --- LES CIBLES QUE LES ROLES DECLARENT (2026-09-14) ----------------------------------
|
||||
#
|
||||
# `serveur_prometheus_cibles_supplementaires` existait depuis longtemps — une liste libre,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue