sondes de sauvegarde : juger le plus recent instantane ; temoin Loki hors index

restic snapshots --latest 1 rend un instantane par groupe (machine et chemins) : la sonde gardait le premier, l'ancien, depuis qu'un jeu a ete ajoute a site-mon-01 (EN RETARD a tort, PERIME promis a 50 h). Meme motif cote serveur_backup. Le temoin de Loki ne compte plus l'index compacte. Journal (114), (115).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-08 17:35:28 -04:00
parent 9ca9a5ffe1
commit 0f7c89fbb5
7 changed files with 173 additions and 5 deletions

View file

@ -1,5 +1,60 @@
# CHANGELOG — Set-OPS
## 2026-10-08 (115) — La sonde du dépôt jugeait le premier groupe d'instantanés, pas le plus récent
**94 preuves.** Question de l'exploitant : « on a aussi les sondes pour vérifier que les
sauvegardes sont OK ? » Oui : le contrôle du dépôt (toutes les 4 h : existe, récent, non vide,
lu dans le dépôt distant) et le contrôle de restauration (chaque semaine : relecture d'une
part des données, vraie restauration vérifiée), poussés à Icinga, dont le silence alerte.
Leur état, lu dans Icinga DB des trois écosystèmes : tout OK chez les locataires (dont
`obs-01`), sauf `web-dorsal-01` (rien à sauvegarder, aucune application déployée). Au site :
**`site-mon-01` « EN RETARD : dernier instantané il y a 26 h »**, alors que la sauvegarde de
la nuit était passée à 02:32.
### Cause : le défaut venait de mon chantier de la veille
`restic snapshots --latest 1` rend le dernier instantané de **chaque groupe** (machine ET liste
de chemins). Ajouter `prometheus`, `loki` et `icinga` à `site-mon-01` a changé sa liste : deux
groupes. La sonde gardait `d[0]`, l'ancien, figé au 2026-10-07 02:41. Il n'aurait fait que
vieillir : **« PÉRIMÉ » (critique) à 50 h**, le 2026-10-09 au matin, sur une sauvegarde saine,
et de même sur toute machine qui reçoit un nouveau jeu. Même motif dans
`serveur_backup/verifier-sauvegardes.sh.j2`.
**Fait** : les deux sondes retiennent le plus récent de tous les instantanés rendus.
**Éprouvé** : `test_verifier_depot.py` (dans `make test`), la sonde rendue depuis son gabarit,
`restic` et `curl` doublés : deux groupes, l'ancien (30 h) en premier, verdict OK (1 h) ; un
seul groupe vieux de 30 h, toujours EN RETARD. Témoin : l'ancien gabarit rend « EN RETARD :
30 h », le symptôme du site. `make verifier` conforme, **94/94**.
## 2026-10-08 (114) — Les métriques et les journaux traversent la reconstruction
**94 preuves.** Technolibre rasée et reconstruite sur `2dce6ea` (arrêt à `valider`, entrée
(113) ; recette repassée sur `9ca9a5f`, reprise `DEPUIS=parefeu`). Journal :
`OPS-Technolibre/logs/reconstruction-20261008-131846.log`, puis `…-162819.log`.
### La restauration de Prometheus et de Loki, vécue
**9 jeux** restaurés depuis l'instantané `avant-raser` de 13:18:50, chacun encore au dépôt :
dont `prometheus` et `loki` (obs-01) et `icinga` (mon-01). Pare-feu Proxmox 13/13, 0 critique.
Témoins : **aucun écart**. Prometheus : un bloc vivant couvre le dernier bloc d'avant ; AC
d'Icinga identique ; historique d'Icinga, 803 lignes d'avant, 0 retirée.
**Demandé aux bases elles-mêmes**, sur 12:00–13:18 (avant le rasage) :
- Prometheus : un point toutes les 10 minutes, 15 cibles à chacun ;
- Loki : neuf tranches de 10 minutes, environ 1 000 lignes chacune, jusqu'à 13:18.
### Le témoin de Loki comptait l'index parmi les morceaux
Il disait « 3 morceaux d'avant retirés (rétention ?) ». La base a moins de deux jours, ce ne
pouvait pas être la rétention. Liste récursive de l'instantané contre la base vivante (un
premier `restic ls` sans `--recursive` ne listait que le premier niveau) : les trois sont des
fichiers d'**index** (`chunks/index/index_20734/…tsdb.gz`, dont une sortie du compacteur), que
le compacteur fusionne et remplace. Aucun journal n'était perdu.
**Fait** : le témoin de Loki ne compare que les morceaux, hors `chunks/index/` (que la copie à
froid traite déjà à part). Test : un index compacté ne fait ni écart ni retrait ; témoin :
avec l'ancienne règle, il échoue. `make verifier` conforme, **94/94**.
## 2026-10-08 (113) — La recette jugeait Prometheus 18 secondes après sa relance
**94 preuves.** La reconstruction de Technolibre (`2dce6ea`) s'est arrêtée à `valider` :

View file

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

View file

@ -233,8 +233,13 @@ def jeu_loki(avant: Path, vivant: Path) -> Rapport:
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"))
# LES MORCEAUX, PAS L'INDEX (2026-10-08). `chunks/index/` loge l'index TSDB et la base du
# compacteur : il les fusionne et les remplace. A la reconstruction de Technolibre, trois
# de ces fichiers « d'avant » manquaient, et le temoin les disait retires « (retention ?) »
# — aucun journal ne l'etait (Loki rendait ceux d'avant le rasage, minute par minute).
morceaux = lambda racine: {rel for rel in arbre(racine / "chunks") if not rel.startswith("index/")} # noqa: E731
a = morceaux(avant)
v = morceaux(vivant)
r.compte = len(a)
if a and not (a & v):
r.ecarts.append(f"aucun des {len(a)} morceau(x) de journal d'avant ne subsiste : journaux non restaures")

View file

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

View file

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

View file

@ -327,8 +327,11 @@ def prometheus_loki() -> None:
ecrire(vivant / "chunks" / "fake" / "c", "3")
argv = ["--avant", str(d / "avant"), "--instantane", "x", "--jeu", f"loki={staging}",
"--vivant", f"loki={vivant}"]
# L'index que le compacteur fusionne n'est pas un morceau : sa disparition ne compte pas.
ecrire(avant / "chunks" / "index" / "index_1" / "1791478851-obs-01.tsdb.gz", "index")
rc, out = lancer(argv)
verifier(rc == 0 and "1 morceau(x) nouveau(x)" in out, "Loki restaure, des journaux arrives depuis : sans ecart")
verifier(rc == 0 and "1 morceau(x) nouveau(x)" in out and "retire" not in out,
f"Loki restaure, des journaux arrives depuis, un index compacte : sans ecart ni retrait ({out.strip()[-100:]})")
(vivant / "chunks" / "fake" / "a").unlink()
rc, out = lancer(argv)
verifier(rc == 0 and "retire(s)" in out, "un morceau d'avant retire (retention) : dit, sans ecart")

View file

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