Set-OPS-Public/scripts/exporter_voutes.py
Daniel Allaire 5d7d2a8918 exporter : archives datees, et rien d ecrit si les voutes n ont pas change
Le nom par defaut etait fixe : le second export butait sur le premier.
Il porte maintenant la date (l heure en plus le meme jour) ; un export
de voutes identiques a la derniere archive n ecrit rien. Le LISEZ-MOI
et la doc restaurent la plus recente archive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:37:07 -04:00

141 lines
6.1 KiB
Python

#!/usr/bin/env python3
"""Sort les VOUTES du poste — la moitie du coffre que `exporter_cles.py` n'emporte pas.
CE QUI MANQUAIT (mesure du 2026-09-28). La cle USB portait les CLES qui ouvrent les voutes,
pas les voutes elles-memes. Or les voutes sont gitignorees : elles ne sont sur AUCUNE forge.
Chacune vit sur ce poste et sur le runner de son ecosysteme, et nulle part ailleurs. Poste
et runner perdus ensemble, les cles restauraient l'acces a... rien : tous les secrets de
l'ecosysteme etaient a regenerer.
UNE ARCHIVE A PART, PAS DANS CELLE DES CLES. `restaurer_cles.py` range chaque cle d'apres
son seul NOM (`~/.config` ou `~/.ssh`). Les voutes s'appellent presque toutes `vault.yml` :
elles s'y ecraseraient. Ici, chaque voute garde son chemin relatif aux depots —
`OPS-Chezlepro/inventories/principal/group_vars/all/vault.yml` — et se restaure par :
tar -xf setops-voutes-<date>-<poste>.tar -C "<dossier des depots>"
PAS DE SECONDE COUCHE DE CHIFFREMENT, et c'est fonde : chaque voute est deja chiffree
(AES256, `ansible-vault`) par une cle qui n'est PAS dans cette archive. Le script REFUSE
donc tout fichier dont l'en-tete n'est pas `$ANSIBLE_VAULT` : une voute dechiffree par
accident ne sortira jamais du poste par ce chemin.
ECRIRE PUIS RELIRE (D-68) : l'archive est rouverte et l'empreinte de CHAQUE voute comparee
a l'original. Au moindre ecart, elle est supprimee.
python3 scripts/exporter_voutes.py --recenser
python3 scripts/exporter_voutes.py --vers "/media/<support>" # -> setops-voutes-<date>-<poste>.tar
"""
from __future__ import annotations
import argparse
import hashlib
import os
import sys
import tarfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from exporter_cles import _archive_libre, _refuser_destination_interne # noqa: E402
DEPOTS = Path(__file__).resolve().parents[2]
MOTIFS = ("*/inventories/*/group_vars/**/*vault.yml", "*/*.vault.yml")
ENTETE = b"$ANSIBLE_VAULT"
def _empreinte(chemin: Path) -> str:
return hashlib.sha256(chemin.read_bytes()).hexdigest()
def _derniere_archive(dest: Path, nom: str) -> Path | None:
"""La plus recente archive de CE poste sur le support (datee ou non)."""
poste = os.uname().nodename
candidates = [a for a in dest.glob(f"{nom}-*{poste}.tar") if a.is_file()]
return max(candidates, key=lambda a: a.stat().st_mtime, default=None)
def _contenu(archive: Path) -> dict[str, str] | None:
"""{chemin: empreinte} de chaque voute de l'archive ; None si elle ne se lit pas."""
try:
with tarfile.open(archive, "r") as tar:
return {m.name: hashlib.sha256(tar.extractfile(m).read()).hexdigest()
for m in tar.getmembers() if m.isfile()}
except (OSError, tarfile.TarError):
return None
def recenser() -> list[Path]:
trouves = {f for m in MOTIFS for f in DEPOTS.glob(m) if f.is_file()}
return sorted(trouves)
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--recenser", action="store_true", help="montrer, sans rien ecrire")
ap.add_argument("--vers", help="repertoire de destination (support amovible)")
ap.add_argument("--nom", default="setops-voutes", help="nom de base de l'archive")
args = ap.parse_args(argv)
voutes = recenser()
en_clair = [f for f in voutes if not f.read_bytes().startswith(ENTETE)]
print(f"\n LES VOUTES DE CE POSTE ({DEPOTS}) :\n")
for f in voutes:
etat = "chiffree" if f not in en_clair else "EN CLAIR — REFUSEE"
print(f" {str(f.relative_to(DEPOTS)):<66} {f.stat().st_size:>6} o "
f"{_empreinte(f)[:16]}… {etat}")
print(f"\n {len(voutes)} voute(s).")
if en_clair:
raise SystemExit("\n REFUS : une voute n'est pas chiffree. Rien ne sort du poste tant "
"qu'elle ne l'est pas.")
if not voutes:
raise SystemExit(" Aucune voute trouvee : rien a sortir.")
if args.recenser:
print("\n RECENSEMENT SEUL — rien n'a ete ecrit.\n")
return 0
if not args.vers:
raise SystemExit(" --vers est requis pour ecrire.")
dest = Path(args.vers).expanduser()
_refuser_destination_interne(dest)
if not dest.is_dir():
raise SystemExit(f" REFUS : {dest} n'est pas un repertoire (support monte ?).")
attendu = {str(f.relative_to(DEPOTS)): _empreinte(f) for f in voutes}
# RIEN DE NOUVEAU, RIEN D'ECRIT (2026-10-04). Si la derniere archive de ce poste sur le
# support porte exactement ces voutes, une copie de plus n'ajoute rien.
precedente = _derniere_archive(dest, args.nom)
if precedente is not None and _contenu(precedente) == attendu:
print(f"\n A JOUR : {precedente.name} porte deja ces {len(attendu)} voute(s), "
"empreinte pour empreinte.\n Rien n'a ete ecrit.\n")
return 0
archive = _archive_libre(dest, args.nom, ".tar")
with tarfile.open(archive, "w") as tar:
for f in voutes:
tar.add(f, arcname=str(f.relative_to(DEPOTS)))
archive.chmod(0o600)
ecarts = []
with tarfile.open(archive, "r") as tar:
noms = tar.getnames()
for nom, emp in attendu.items():
membre = tar.extractfile(nom) if nom in noms else None
if membre is None:
ecarts.append(f"{nom} : ABSENT")
elif hashlib.sha256(membre.read()).hexdigest() != emp:
ecarts.append(f"{nom} : empreinte differente")
ecarts += [f"{n} : EN TROP" for n in noms if n not in attendu]
if ecarts:
archive.unlink(missing_ok=True)
print("\n ECARTS — l'archive a ete SUPPRIMEE :", file=sys.stderr)
for e in ecarts:
print(f" ! {e}", file=sys.stderr)
return 1
print(f"\n Ecrit : {archive}")
print(f" {len(attendu)} voute(s) relue(s), empreintes identiques a l'original.")
print(f" Restaurer : tar -xf \"{archive.name}\" -C \"<dossier des depots>\"")
print(" Les CLES qui les ouvrent sont dans l'archive `setops-cles-*` — pas dans celle-ci.\n")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))