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>
412 lines
22 KiB
Python
412 lines
22 KiB
Python
#!/usr/bin/env python3
|
|
"""La reconstruction REMET l'etat d'avant — et chaque detenteur d'etat sait le faire.
|
|
|
|
POURQUOI (2026-09-30). Technolibre a ete reconstruite, 0 echec, `make valider` vert — et
|
|
rien n'etait revenu : ni l'annuaire (le mot de passe de `sysadmin` etait celui de
|
|
l'amorcage), ni la racine de l'AC, ni les bases, ni Nextcloud. « Restauration verifiee »
|
|
voulait dire « restauree dans un repertoire temporaire, lue, jetee ».
|
|
|
|
Ce test verifie deux choses :
|
|
|
|
1. COUVERTURE (statique). Chaque groupe detenteur d'etat (`client_backup_groupes_etat`)
|
|
inclut `restaurer.yml` depuis son propre role. Une liste qui en suit une autre prend du
|
|
retard : ajouter un detenteur d'etat sans son point de restauration doit echouer ICI.
|
|
|
|
2. L'OUTIL DE NOEUD (`setops-restaurer`), rendu depuis son vrai gabarit, avec `restic`,
|
|
`psql` & co. remplaces par des doublures :
|
|
- le candidat est le dernier instantane ANTERIEUR a la naissance de la machine ;
|
|
- un repertoire non vide n'est jamais ecrase sans `--remplacer` ;
|
|
- la sauvegarde refuse de deposer tant qu'un etat d'avant attend (code 3), et
|
|
reprend une fois chaque jeu acte ; un depot inexistant = premiere vie (« neuf ») ;
|
|
- la section d'une base s'arrete avant le `DROP DATABASE postgres;` que `pg_dumpall
|
|
--clean` place apres la derniere base (le piege du runbook, rencontre le 2026-09-30).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import jinja2
|
|
import yaml
|
|
|
|
RACINE = Path(__file__).resolve().parents[2]
|
|
GABARIT = RACINE / "roles/client_backup/templates/restaurer.sh.j2"
|
|
SAUVEGARDE = RACINE / "roles/client_backup/templates/sauvegarder.sh.j2"
|
|
COMPARATEUR = RACINE / "roles/client_backup/files/setops-temoins.py"
|
|
|
|
ECHECS: list[str] = []
|
|
|
|
|
|
def verifier(cond: bool, msg: str) -> None:
|
|
print(("OK " if cond else "ECHEC ") + msg)
|
|
if not cond:
|
|
ECHECS.append(msg)
|
|
|
|
|
|
# --- 1. Couverture -------------------------------------------------------------------
|
|
|
|
def couverture() -> None:
|
|
groupes = yaml.safe_load((RACINE / "roles/client_backup/vars/main.yml").read_text())[
|
|
"client_backup_groupes_etat"]
|
|
for g in groupes:
|
|
taches = RACINE / "roles" / g / "tasks"
|
|
texte = "".join(p.read_text() for p in sorted(taches.glob("*.yml"))) if taches.is_dir() else ""
|
|
verifier(f"client_backup_restaurer_jeu: {g}" in texte,
|
|
f"{g} inclut son point de restauration (restaurer.yml)")
|
|
verifier("{#" not in GABARIT.read_text(), "le gabarit ne contient aucune sequence accolade-diese")
|
|
|
|
# UN ICINGA NEUF ENTEND LE NOEUD TOUT DE SUITE (2026-09-30) : sinon `restauration` reste
|
|
# rouge jusqu'au dimanche apres chaque reconstruction.
|
|
# PAS SUR LA COPIE DE L'AC : `client_sante` la depose aussi, plus tot — sur une flotte
|
|
# neuve elle ne change donc jamais ici (reconstruction de Chezlepro, 2026-09-30).
|
|
taches = yaml.safe_load((RACINE / "roles/client_backup/tasks/verifier.yml").read_text())
|
|
notifiants = [t.get("name", "") for t in taches if t.get("notify") == "Premier rapport a Icinga"]
|
|
verifier(notifiants and all("AC d'Icinga pour la" not in n for n in notifiants)
|
|
and any("jamais entendus" in n for n in notifiants),
|
|
"le premier rapport part d'un marqueur propre a client_backup, pas de la copie partagee de l'AC")
|
|
gest = yaml.safe_load((RACINE / "roles/client_backup/handlers/main.yml").read_text())
|
|
ecoute = [g for g in gest if g.get("listen") == "Premier rapport a Icinga"]
|
|
ordre = [g.get("ansible.builtin.systemd", {}).get("name", "marqueur") for g in ecoute]
|
|
verifier(ordre == ["setops-sauvegarde.service", "setops-verification-depot.service",
|
|
"setops-verification-restauration.service", "marqueur"],
|
|
f"premier rapport : deposer, verifier le depot, la restauration, PUIS retenir ({ordre})")
|
|
|
|
|
|
# --- 2. L'outil de noeud, avec des doublures ------------------------------------------
|
|
|
|
def executable(chemin: Path, texte: str) -> None:
|
|
chemin.write_text(texte)
|
|
chemin.chmod(chemin.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
|
|
|
|
DUMP = r"""--
|
|
DROP DATABASE nextcloud;
|
|
DROP DATABASE keycloak;
|
|
CREATE DATABASE keycloak WITH TEMPLATE = template0 ENCODING = 'UTF8' LOCALE = 'fr_CA.UTF-8';
|
|
ALTER DATABASE keycloak OWNER TO keycloak;
|
|
\connect keycloak
|
|
CREATE TABLE realm (id text);
|
|
COPY realm FROM stdin;
|
|
\.
|
|
CREATE DATABASE nextcloud WITH TEMPLATE = template0 ENCODING = 'UTF8' LOCALE = 'fr_CA.UTF-8';
|
|
ALTER DATABASE nextcloud OWNER TO nextcloud;
|
|
\connect nextcloud
|
|
CREATE TABLE oc_users (uid text);
|
|
REVOKE CONNECT,TEMPORARY ON DATABASE nextcloud FROM PUBLIC;
|
|
DROP DATABASE postgres;
|
|
CREATE DATABASE postgres WITH TEMPLATE = template0;
|
|
\connect postgres
|
|
"""
|
|
|
|
|
|
def outil() -> None:
|
|
with tempfile.TemporaryDirectory() as d:
|
|
d = Path(d)
|
|
bin_, fixt, log = d / "bin", d / "instantanes", d / "journal"
|
|
for p in (bin_, fixt, log):
|
|
p.mkdir()
|
|
cle = d / "ssh_host_ed25519_key.pub"
|
|
cle.write_text("cle\n") # nee MAINTENANT
|
|
staging = d / "staging"
|
|
|
|
# Deux instantanes : l'un d'AVANT la naissance, l'autre d'apres (l'etat neuf).
|
|
avant, apres = fixt / "avant01", fixt / "apres02"
|
|
for snap, contenu in ((avant, "ancien"), (apres, "neuf")):
|
|
(snap / str(d / "vmail").lstrip("/") / "boite").mkdir(parents=True)
|
|
(snap / str(d / "vmail").lstrip("/") / "boite" / "msg").write_text(contenu)
|
|
dump = snap / str(staging).lstrip("/") / "postgresql" / "toutes-bases.sql"
|
|
dump.parent.mkdir(parents=True)
|
|
dump.write_text(DUMP)
|
|
# Le troisieme, etiquete au rasage, est PLUS ANCIEN que le candidat : les temoins
|
|
# doivent le choisir quand meme (c'est l'etiquette qui designe, pas l'heure).
|
|
rasage = fixt / "rasage03" / str(d / "vmail").lstrip("/") / "boite" / "cur"
|
|
rasage.mkdir(parents=True)
|
|
(rasage / "1700.M1.h:2,S").write_text("le courriel d'avant")
|
|
snaps = [
|
|
{"short_id": "rasage03", "id": "rasage03" + "0" * 56, "time": "2026-09-29T10:00:00-04:00",
|
|
"tags": ["setops", "avant-raser"]},
|
|
{"short_id": "avant01", "id": "avant01" + "0" * 56, "time": "2026-09-29T23:50:50.123456789-04:00",
|
|
"tags": ["setops"]},
|
|
{"short_id": "apres02", "id": "apres02" + "0" * 56, "time": "2099-01-01T00:00:00Z", "tags": ["setops"]},
|
|
]
|
|
(d / "snapshots.json").write_text(json.dumps(snaps))
|
|
|
|
# restic : `snapshots --json` et `restore ID --target T --include P...`.
|
|
# SETOPS_TEST_DEPOT_ABSENT=1 imite restic 0.17+ face a un depot inexistant (code 10).
|
|
executable(bin_ / "restic", f"""#!/bin/bash
|
|
if [[ -n "${{SETOPS_TEST_DEPOT_ABSENT:-}}" ]]; then exit 10; fi
|
|
# L'outil passe `--retry-lock <duree>` avant la sous-commande (74) : on l'exige, puis on le saute.
|
|
[[ "$1" == --retry-lock && -n "$2" ]] || {{ echo "restic appele sans --retry-lock : $*" >&2; exit 99; }}
|
|
shift 2
|
|
case "$1" in
|
|
snapshots)
|
|
etiquette=""
|
|
while (( $# )); do [[ "$1" == --tag ]] && etiquette="$2"; shift; done
|
|
python3 -c 'import json, sys; e = sys.argv[2]
|
|
print(json.dumps([x for x in json.load(open(sys.argv[1])) if not e or e in x.get("tags", [])]))' \
|
|
"{d}/snapshots.json" "$etiquette" ;;
|
|
restore)
|
|
id="$2"; shift 2; cible=""; inc=()
|
|
while (( $# )); do case "$1" in --target) cible="$2"; shift 2;; --include) inc+=("$2"); shift 2;; *) shift;; esac; done
|
|
for i in "${{inc[@]}}"; do
|
|
[[ -e "{fixt}/$id$i" ]] || continue
|
|
mkdir -p "$cible$(dirname "$i")"; cp -a "{fixt}/$id$i" "$cible$(dirname "$i")/"
|
|
done ;;
|
|
esac
|
|
""")
|
|
# PostgreSQL : on journalise, on garde la section rejouee ; toute base est vierge.
|
|
executable(bin_ / "runuser", '#!/bin/bash\nwhile [[ "$1" != "--" ]]; do shift; done; shift; exec "$@"\n')
|
|
# Sans `-c`, psql lit la section sur son entree standard : c'est ainsi que l'outil
|
|
# doit la lui passer (`postgres` ne lit pas le repertoire jetable de root).
|
|
executable(bin_ / "psql", f"""#!/bin/bash
|
|
echo "psql $*" >> "{log}/pg"
|
|
avec_c=0
|
|
while (( $# )); do
|
|
case "$1" in -f) echo "psql -f INTERDIT" >> "{log}/pg"; shift 2;; -c) avec_c=1; [[ "$2" == select* ]] && echo 0; shift 2;; *) shift;; esac
|
|
done
|
|
if (( ! avec_c )); then cat > "{log}/section.sql"; fi
|
|
""")
|
|
executable(bin_ / "pg_dump", '#!/bin/bash\necho dump\n')
|
|
executable(bin_ / "dropdb", f'#!/bin/bash\necho "dropdb $*" >> "{log}/pg"\n')
|
|
|
|
executable(bin_ / "setops-temoins", f'#!/bin/bash\nexec python3 "{COMPARATEUR}" "$@"\n')
|
|
|
|
def rendre(nom: str, jeux: list[dict]) -> Path:
|
|
rendu = jinja2.Environment(undefined=jinja2.StrictUndefined).from_string(
|
|
GABARIT.read_text()).render(
|
|
client_backup_repo="sftp:restic@depot:hote",
|
|
inventory_hostname="hote",
|
|
client_backup_restauration_marques=str(d / "marques"),
|
|
client_backup_restauration_mise_de_cote=str(d / "mis-de-cote"),
|
|
client_backup_staging=str(staging),
|
|
client_backup_jobs=jeux,
|
|
client_backup_attente_verrou="30m",
|
|
client_backup_etiquette_avant_raser="avant-raser",
|
|
).replace("/etc/ssh/ssh_host_ed25519_key.pub", str(cle)) \
|
|
.replace("/etc/setops/restic.pass", str(d / "restic.pass")) \
|
|
.replace("/usr/local/sbin/setops-temoins", str(bin_ / "setops-temoins"))
|
|
executable(d / nom, rendu)
|
|
return d / nom
|
|
|
|
vmail_jeu = {"nom": "courriel", "chemins": [str(d / "vmail")]}
|
|
script = rendre("setops-restaurer", [vmail_jeu, {"nom": "postgresql", "chemins": [str(staging / "postgresql")]}])
|
|
env = {**os.environ, "PATH": f"{bin_}:{os.environ['PATH']}"}
|
|
|
|
def lancer(*args: str, outil: Path = script, **extra: str) -> subprocess.CompletedProcess:
|
|
return subprocess.run(["bash", str(outil), *args], env={**env, **extra},
|
|
capture_output=True, text=True)
|
|
|
|
r = lancer("choisir")
|
|
verifier(r.returncode == 0 and json.loads(r.stdout)["instantane"] == "avant01",
|
|
f"le candidat est l'instantane d'AVANT la naissance, pas l'etat neuf ({r.stdout.strip() or r.stderr.strip()})")
|
|
|
|
r = lancer("garde")
|
|
verifier(r.returncode == 3, f"la sauvegarde refuse tant qu'un etat d'avant attend (code {r.returncode})")
|
|
|
|
vmail = d / "vmail"
|
|
(vmail / "neuf").mkdir(parents=True)
|
|
(vmail / "neuf" / "x").write_text("x")
|
|
r = lancer("fichiers", str(vmail))
|
|
verifier(r.returncode != 0 and (vmail / "neuf" / "x").exists(),
|
|
"un repertoire non vide n'est pas ecrase sans --remplacer")
|
|
|
|
r = lancer("fichiers", "--remplacer", str(vmail))
|
|
verifier(r.returncode == 0 and (vmail / "boite" / "msg").read_text() == "ancien"
|
|
and not (vmail / "neuf").exists(),
|
|
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)
|
|
verifier(r.returncode == 1 and "instantane rasage03" in r.stdout and "perdu : boite/msg/1700.M1.h" in r.stdout,
|
|
f"les temoins comparent a l'instantane etiquete au rasage, et voient le courriel perdu "
|
|
f"(code {r.returncode}) {r.stdout.strip()[-200:] or r.stderr.strip()[-200:]}")
|
|
r = lancer("temoins", "--candidat", outil=seul_courriel)
|
|
verifier(r.returncode == 0 and "instantane avant01" in r.stdout,
|
|
f"--candidat compare au candidat, que la restauration a remis a l'identique ({r.stdout.strip()[-160:]})")
|
|
r = lancer("temoins", outil=rendre("setops-restaurer-vide", []))
|
|
verifier(r.returncode == 4 and "SANS OBJET" in r.stdout, "un noeud sans etat : rien a comparer (code 4)")
|
|
|
|
r = lancer("base", "nextcloud")
|
|
section = (log / "section.sql").read_text() if (log / "section.sql").exists() else ""
|
|
verifier(r.returncode == 0 and "oc_users" in section, f"la section nextcloud est rejouee ({r.stderr.strip()[:120]})")
|
|
verifier("DROP DATABASE" not in section and "realm" not in section,
|
|
"la section s'arrete avant le DROP DATABASE postgres, et ne deborde sur aucune autre base")
|
|
pg = (log / "pg").read_text()
|
|
verifier("--single-transaction" in pg, "la base est rejouee en une seule transaction")
|
|
verifier("INTERDIT" not in pg, "la section passe par l'entree standard, jamais par `psql -f`")
|
|
|
|
r = lancer("base", "inexistante")
|
|
verifier(r.returncode == 0 and "ABSENTE" in r.stdout, "une base absente de l'instantane est dite, pas inventee")
|
|
|
|
lancer("acter", "courriel", "restaure", "chasse09")
|
|
lancer("acter", "postgresql", "restaure", "avant01")
|
|
r = lancer("garde")
|
|
verifier(r.returncode == 0, "la sauvegarde reprend une fois chaque jeu acte")
|
|
|
|
# LE BILAN DIT CE QUI A ETE RESTAURE, ET S'IL EST ENCORE AU DEPOT (2026-10-07).
|
|
r = lancer("etat")
|
|
verifier("jeu courriel : etat=restaure instantane=chasse09" in r.stdout and "(RETIRE du depot)" in r.stdout,
|
|
"un instantane restaure puis chasse du depot est dit RETIRE")
|
|
verifier("instantane=avant01" in r.stdout and "(au depot)" in r.stdout, "un instantane encore la est dit au depot")
|
|
verifier("avant-raser: rasage03" in r.stdout and "lancee maintenant" in r.stdout,
|
|
"le bilan nomme l'instantane d'avant rasage, et dit que le candidat est calcule maintenant")
|
|
|
|
for f in (d / "marques").iterdir():
|
|
f.unlink()
|
|
r = lancer("garde", SETOPS_TEST_DEPOT_ABSENT="1")
|
|
marques = sorted(p.name for p in (d / "marques").iterdir())
|
|
verifier(r.returncode == 0 and marques == ["courriel", "postgresql"]
|
|
and "etat=neuf" in (d / "marques" / "courriel").read_text(),
|
|
"depot inexistant = premiere vie : chaque jeu est acte « neuf »")
|
|
|
|
|
|
# --- 3. Le depot d'avant rasage (2026-10-07) ---------------------------------------------
|
|
|
|
def sauvegarde() -> None:
|
|
"""`--avant-raser` etiquette le depot ; l'etiquette passe du precedent au nouveau ; la
|
|
retention la garde toujours. A M4, le premier depot d'apres reconstruction avait chasse
|
|
l'instantane d'avant par `--keep-daily` : plus rien a quoi comparer l'etat remis."""
|
|
with tempfile.TemporaryDirectory() as d:
|
|
d = Path(d)
|
|
bin_ = d / "bin"
|
|
bin_.mkdir()
|
|
(d / "vmail").mkdir()
|
|
journal = d / "restic.log"
|
|
# Deux instantanes etiquetes : l'ancien rasage, et celui qui vient d'etre depose.
|
|
etiquetes = [{"id": "ancien" + "0" * 58, "time": "2026-10-01T10:00:00-04:00"},
|
|
{"id": "nouveau" + "0" * 57, "time": "2026-10-07T11:34:44-04:00"}]
|
|
(d / "etiquetes.json").write_text(json.dumps(etiquetes))
|
|
executable(bin_ / "restic", f"""#!/bin/bash
|
|
[[ "$1" == --retry-lock ]] && shift 2
|
|
echo "$*" >> "{journal}"
|
|
if [[ "$1" == snapshots && "$*" == *--json* ]]; then cat "{d}/etiquetes.json"; fi
|
|
exit 0
|
|
""")
|
|
executable(bin_ / "setops-restaurer", "#!/bin/bash\nexit 0\n")
|
|
rendu = jinja2.Environment(undefined=jinja2.StrictUndefined).from_string(
|
|
SAUVEGARDE.read_text()).render(
|
|
client_backup_repo="sftp:restic@depot:hote", inventory_hostname="hote",
|
|
client_backup_attente_verrou="30m", client_backup_staging=str(d / "staging"),
|
|
client_backup_jobs=[{"nom": "courriel", "chemins": [str(d / "vmail")]}],
|
|
client_backup_retention="--keep-daily 7", client_backup_etiquette_avant_raser="avant-raser",
|
|
).replace("/usr/local/sbin/setops-restaurer", str(bin_ / "setops-restaurer"))
|
|
script = d / "setops-sauvegarder.sh"
|
|
executable(script, rendu)
|
|
env = {**os.environ, "PATH": f"{bin_}:{os.environ['PATH']}"}
|
|
|
|
def lancer(*args: str) -> list[str]:
|
|
journal.unlink(missing_ok=True)
|
|
r = subprocess.run(["bash", str(script), *args], env=env, capture_output=True, text=True)
|
|
verifier(r.returncode == 0, f"la sauvegarde {' '.join(args) or 'ordinaire'} aboutit ({r.stderr.strip()[-160:]})")
|
|
return journal.read_text().splitlines() if journal.exists() else []
|
|
|
|
appels = lancer("--avant-raser")
|
|
backup = next((a for a in appels if a.startswith("backup ")), "")
|
|
verifier("--tag avant-raser" in backup, f"le depot d'avant rasage est etiquete ({backup})")
|
|
retrait = [a for a in appels if a.startswith("tag ")]
|
|
verifier(retrait == ["tag --remove avant-raser " + etiquetes[0]["id"]],
|
|
f"l'etiquette est retiree au precedent, et a lui seul ({retrait})")
|
|
verifier(appels.index(backup) < appels.index(retrait[0]) if retrait else False,
|
|
"l'etiquette n'est retiree qu'APRES le nouveau depot")
|
|
forget = next((a for a in appels if a.startswith("forget ")), "")
|
|
verifier("--keep-tag avant-raser" in forget, f"la retention garde l'etiquette ({forget})")
|
|
|
|
appels = lancer()
|
|
backup = next((a for a in appels if a.startswith("backup ")), "")
|
|
verifier("avant-raser" not in backup and not any(a.startswith("tag ") for a in appels),
|
|
"un depot ordinaire n'est pas etiquete et ne touche pas l'etiquette")
|
|
forget = next((a for a in appels if a.startswith("forget ")), "")
|
|
verifier("--keep-tag avant-raser" in forget,
|
|
"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
|
|
print("\nLa restauration est couverte et l'outil se comporte comme annonce.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|