diff --git a/CHANGELOG.md b/CHANGELOG.md index d1ddafa..f4b543c 100644 --- a/CHANGELOG.md +++ b/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/`, 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 `, 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`, diff --git a/roles/client_backup/defaults/main.yml b/roles/client_backup/defaults/main.yml index af21b19..e120bcb 100644 --- a/roles/client_backup/defaults/main.yml +++ b/roles/client_backup/defaults/main.yml @@ -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 diff --git a/roles/client_backup/files/setops-copie-a-froid.sh b/roles/client_backup/files/setops-copie-a-froid.sh new file mode 100644 index 0000000..b613085 --- /dev/null +++ b/roles/client_backup/files/setops-copie-a-froid.sh @@ -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")" diff --git a/roles/client_backup/files/setops-temoins.py b/roles/client_backup/files/setops-temoins.py index baf0b31..e86f891 100644 --- a/roles/client_backup/files/setops-temoins.py +++ b/roles/client_backup/files/setops-temoins.py @@ -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)" diff --git a/roles/client_backup/tasks/main.yml b/roles/client_backup/tasks/main.yml index 37be89e..69ec34c 100644 --- a/roles/client_backup/tasks/main.yml +++ b/roles/client_backup/tasks/main.yml @@ -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 diff --git a/roles/client_backup/templates/restaurer.sh.j2 b/roles/client_backup/templates/restaurer.sh.j2 index b4b600a..fa0273c 100644 --- a/roles/client_backup/templates/restaurer.sh.j2 +++ b/roles/client_backup/templates/restaurer.sh.j2 @@ -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 diff --git a/roles/client_backup/vars/main.yml b/roles/client_backup/vars/main.yml index 2fab06a..0f7a946 100644 --- a/roles/client_backup/vars/main.yml +++ b/roles/client_backup/vars/main.yml @@ -21,3 +21,5 @@ client_backup_groupes_etat: - serveur_rspamd - serveur_web_dorsal - serveur_icinga + - serveur_prometheus + - serveur_loki diff --git a/roles/serveur_loki/tasks/main.yml b/roles/serveur_loki/tasks/main.yml index d5a6ce8..b8c15d3 100644 --- a/roles/serveur_loki/tasks/main.yml +++ b/roles/serveur_loki/tasks/main.yml @@ -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: diff --git a/roles/serveur_prometheus/defaults/main.yml b/roles/serveur_prometheus/defaults/main.yml index 3fc5a21..c09d2c4 100644 --- a/roles/serveur_prometheus/defaults/main.yml +++ b/roles/serveur_prometheus/defaults/main.yml @@ -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" diff --git a/roles/serveur_prometheus/tasks/main.yml b/roles/serveur_prometheus/tasks/main.yml index 388424e..87be9ce 100644 --- a/roles/serveur_prometheus/tasks/main.yml +++ b/roles/serveur_prometheus/tasks/main.yml @@ -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, diff --git a/scripts/tests/test_restauration.py b/scripts/tests/test_restauration.py index 9ffbdbd..bf20c40 100644 --- a/scripts/tests/test_restauration.py +++ b/scripts/tests/test_restauration.py @@ -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 diff --git a/scripts/tests/test_temoins.py b/scripts/tests/test_temoins.py index 94a642d..4fa7030 100644 --- a/scripts/tests/test_temoins.py +++ b/scripts/tests/test_temoins.py @@ -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