diff --git a/CHANGELOG.md b/CHANGELOG.md index 62f982f..0a3a9fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` : diff --git a/Makefile b/Makefile index 938bd2e..947691f 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/roles/client_backup/files/setops-temoins.py b/roles/client_backup/files/setops-temoins.py index e86f891..a22ffff 100644 --- a/roles/client_backup/files/setops-temoins.py +++ b/roles/client_backup/files/setops-temoins.py @@ -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") diff --git a/roles/client_backup/templates/verifier-mon-depot.sh.j2 b/roles/client_backup/templates/verifier-mon-depot.sh.j2 index 16735fb..deae378 100644 --- a/roles/client_backup/templates/verifier-mon-depot.sh.j2 +++ b/roles/client_backup/templates/verifier-mon-depot.sh.j2 @@ -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. diff --git a/roles/serveur_backup/templates/verifier-sauvegardes.sh.j2 b/roles/serveur_backup/templates/verifier-sauvegardes.sh.j2 index 7800026..21fe4e7 100644 --- a/roles/serveur_backup/templates/verifier-sauvegardes.sh.j2 +++ b/roles/serveur_backup/templates/verifier-sauvegardes.sh.j2 @@ -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 diff --git a/scripts/tests/test_temoins.py b/scripts/tests/test_temoins.py index 4fa7030..85ae33a 100644 --- a/scripts/tests/test_temoins.py +++ b/scripts/tests/test_temoins.py @@ -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") diff --git a/scripts/tests/test_verifier_depot.py b/scripts/tests/test_verifier_depot.py new file mode 100644 index 0000000..76a5ade --- /dev/null +++ b/scripts/tests/test_verifier_depot.py @@ -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())