diff --git a/CHANGELOG.md b/CHANGELOG.md index 8658a8d..3944085 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # CHANGELOG — Set-OPS +## 2026-10-04 (75) — Exporter les voûtes une seconde fois ne bute plus sur la première + +**Signalé par l'exploitant** : `make voutes-exporter VERS=` a refusé (« existe deja. +On n'ecrase pas une sauvegarde »). Il a dû retirer l'ancienne archive à la main, puis relancer. + +**Cause** : le nom par défaut était fixe (`setops-voutes-.tar`, et de même +`setops-cles-.tar[.gpg]`). La docstring promettait `setops-voutes--`, +mais le code ne datait pas. Les exports précédents n'avaient passé que parce qu'on datait +soi-même par `NOM=`. + +**Fait** (`scripts/exporter_cles.py`, `scripts/exporter_voutes.py`) : +- le nom porte la date (`-AAAA-MM-JJ-`), et l'heure en plus au second export du + même jour. Le refus de l'écrasement reste, en dernier recours ; +- voûtes : si la dernière archive de ce poste sur le support porte exactement ces voûtes + (mêmes chemins, mêmes empreintes), **rien n'est écrit** (« A JOUR »). Une copie de plus + n'ajoute rien ; +- le LISEZ-MOI déposé sur la clé et `docs/sortir-les-cles-du-poste.md` restaurent la **plus + récente** archive (`ls -t … | head -1`). Leur `setops-voutes-*.tar`, avec plusieurs + archives sur le support, passait plusieurs noms à `tar -xf`. + +**Éprouvé** sur un support simulé (scratchpad, effacé ensuite) : premier export daté ; +second export identique → « A JOUR », rien d'écrit ; une voûte changée (archive précédente +altérée) → nouvelle archive horodatée à côté de la première. + +Le LISEZ-MOI déjà sur la clé garde l'ancienne formule ; `make cles-compagnons +VERS=` le remplace sans refaire l'archive. + ## 2026-10-04 (74) — Un dépôt verrouillé n'est plus une panne **Constaté en vérifiant (73)** : lancées ensemble sur `web-dorsal-01`, la vérification du diff --git a/docs/sortir-les-cles-du-poste.md b/docs/sortir-les-cles-du-poste.md index b8e27c5..8afa580 100644 --- a/docs/sortir-les-cles-du-poste.md +++ b/docs/sortir-les-cles-du-poste.md @@ -15,7 +15,10 @@ ce poste et sur le runner de son écosystème, qui en reçoit une copie chiffré d'une seconde couche ; le script refuse tout fichier dont l'en-tête n'est pas `$ANSIBLE_VAULT`, garde le chemin de chaque voûte (elles s'appellent presque toutes `vault.yml`) et relit l'archive empreinte par empreinte. Restauration : -`tar -xf setops-voutes-*.tar -C `. **À refaire après toute écriture +`tar -xf "$(ls -t setops-voutes-*.tar | head -1)" -C ` (la plus récente). +Chaque export porte sa date (`setops-voutes--.tar`, l'heure en plus au second +export du jour) et n'écrit rien si les voûtes n'ont pas changé depuis la dernière archive. +**À refaire après toute écriture dans une voûte**, comme les clés après toute voûte nouvelle. > **Ce chiffre était trois, et il a baissé sans que rien ne le signale.** Un coffre répliqué deux fois reste @@ -77,7 +80,7 @@ Une procédure rangée dans le dépôt serait donc inaccessible exactement quand La clé se suffit donc à elle-même. À côté de l'archive : ``` -setops-cles-.tar.gpg les clés, chiffrées +setops-cles--.tar.gpg les clés, chiffrées (une archive datée par export) restaurer_cles.py le script, autonome LISEZ-MOI-RESTAURATION.txt le mode d'emploi, et les commandes manuelles ``` @@ -89,8 +92,9 @@ LISEZ-MOI-RESTAURATION.txt le mode d'emploi, et les commandes manuelles Sur la machine neuve, il ne faut que `gpg`, `python3` et la phrase de passe : ```bash -python3 restaurer_cles.py setops-cles-*.tar.gpg --lister # voir sans rien écrire -python3 restaurer_cles.py setops-cles-*.tar.gpg # remettre en place +A="$(ls -t setops-cles-*.tar* | head -1)" # la plus récente +python3 restaurer_cles.py "$A" --lister # voir sans rien écrire +python3 restaurer_cles.py "$A" # remettre en place ``` Le script remet chaque fichier à sa place selon son nom, et **repose les droits à 0600**. diff --git a/scripts/exporter_cles.py b/scripts/exporter_cles.py index ef34096..00c00fd 100644 --- a/scripts/exporter_cles.py +++ b/scripts/exporter_cles.py @@ -33,6 +33,7 @@ qu'il vient d'ecrire et compare les empreintes, une a une. from __future__ import annotations import argparse +import datetime as dt import hashlib import os import subprocess @@ -94,6 +95,23 @@ def _refuser_destination_interne(dest: Path) -> None: " cle est a l'interieur. Viser un support amovible ou une machine tierce.") +def _archive_libre(dest: Path, nom: str, ext: str) -> Path: + """Le nom de la NOUVELLE archive : `--` (2026-10-04). + + Le nom etait fixe (`-`) : le second export butait toujours sur le premier, et + l'exploitant devait deplacer l'ancien a la main ou dater lui-meme par `NOM=`. Dater par + defaut garde l'historique sur le support ; un second export le meme jour prend l'heure. + On n'ecrase jamais : si meme ce nom-la existe, on refuse. + """ + poste = os.uname().nodename + maintenant = dt.datetime.now() + archive = dest / f"{nom}-{maintenant:%Y-%m-%d}-{poste}{ext}" + if archive.exists(): + archive = dest / f"{nom}-{maintenant:%Y-%m-%d-%H%M%S}-{poste}{ext}" + if archive.exists(): + raise SystemExit(f"REFUS : {archive} existe deja. On n'ecrase pas une sauvegarde.") + return archive + LISEZ_MOI = """SI TU LIS CECI, LE POSTE EST PROBABLEMENT MORT. ================================================== @@ -108,8 +126,10 @@ CE QU'IL FAUT, ET RIEN D'AUTRE LA MANOEUVRE ------------ - python3 restaurer_cles.py setops-cles-*.tar.gpg --lister # voir sans rien ecrire - python3 restaurer_cles.py setops-cles-*.tar.gpg # remettre en place + # Une archive par export, datee : prendre la PLUS RECENTE. + A="$(ls -t setops-cles-*.tar* | head -1)" + python3 restaurer_cles.py "$A" --lister # voir sans rien ecrire + python3 restaurer_cles.py "$A" # remettre en place Le script remet chaque fichier a sa place (~/.config ou ~/.ssh) et repose les droits a 0600. Ce dernier point n'est pas cosmetique : `ssh` REFUSE une cle privee que d'autres @@ -121,7 +141,7 @@ SI MEME CE SCRIPT NE TOURNE PAS ------------------------------- Trois commandes suffisent. C'est le vrai filet : - gpg --output cles.tar --decrypt setops-cles-*.tar.gpg + gpg --output cles.tar --decrypt "$(ls -t setops-cles-*.tar.gpg | head -1)" mkdir -p ~/.config ~/.ssh && tar -xf cles.tar -C /tmp cp /tmp/setops-vault-* ~/.config/ ; cp /tmp/id_* ~/.ssh/ chmod 700 ~/.ssh ~/.config ; chmod 600 ~/.ssh/id_* ~/.config/setops-vault-* @@ -131,7 +151,7 @@ LES VOUTES, DANS UNE AUTRE ARCHIVE Les cles ci-dessus OUVRENT les voutes ; les voutes elles-memes (chiffrees, gitignorees, donc sur aucune forge) sont dans `setops-voutes-*.tar`. Elles gardent leur chemin : - tar -xf setops-voutes-*.tar -C "" + tar -xf "$(ls -t setops-voutes-*.tar | head -1)" -C "" Sans elles, les cles n'ouvrent rien : chaque secret des ecosystemes serait a refaire. @@ -254,12 +274,7 @@ def main() -> int: # defaut reste l'archive GPG, parce qu'un support non chiffre est le cas le plus # frequent et que le silence ne doit jamais pencher du cote de la divulgation. if args.support_chiffre: - archive = dest / f"{args.nom}-{os.uname().nodename}.tar" - if archive.exists(): - raise SystemExit( - f"REFUS : {archive} existe deja.\n" - " On n'ecrase pas une sauvegarde de cles : elle est peut-etre la seule.\n" - " La deplacer ou choisir un autre --nom.") + archive = _archive_libre(dest, args.nom, ".tar") with tarfile.open(archive, "w") as tar: for f, _ in fichiers: tar.add(f, arcname=f.name) @@ -281,12 +296,7 @@ def main() -> int: _deposer_compagnons(dest) return 0 - archive = dest / f"{args.nom}-{os.uname().nodename}.tar.gpg" - if archive.exists(): - raise SystemExit( - f"REFUS : {archive} existe deja.\n" - " On n'ecrase pas une sauvegarde de cles : elle est peut-etre la seule.\n" - " La deplacer ou choisir un autre --nom.") + archive = _archive_libre(dest, args.nom, ".tar.gpg") with tempfile.TemporaryDirectory() as tmp: brut = Path(tmp) / "cles.tar" diff --git a/scripts/exporter_voutes.py b/scripts/exporter_voutes.py index 11dd2f9..8af0b4f 100644 --- a/scripts/exporter_voutes.py +++ b/scripts/exporter_voutes.py @@ -23,7 +23,7 @@ ECRIRE PUIS RELIRE (D-68) : l'archive est rouverte et l'empreinte de CHAQUE vout a l'original. Au moindre ecart, elle est supprimee. python3 scripts/exporter_voutes.py --recenser - python3 scripts/exporter_voutes.py --vers "/media/" --nom setops-voutes- + python3 scripts/exporter_voutes.py --vers "/media/" # -> setops-voutes--.tar """ from __future__ import annotations @@ -35,7 +35,7 @@ import tarfile from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) -from exporter_cles import _refuser_destination_interne # noqa: E402 +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") @@ -46,6 +46,23 @@ 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) @@ -81,11 +98,16 @@ def main(argv: list[str]) -> int: _refuser_destination_interne(dest) if not dest.is_dir(): raise SystemExit(f" REFUS : {dest} n'est pas un repertoire (support monte ?).") - archive = dest / f"{args.nom}-{os.uname().nodename}.tar" - if archive.exists(): - raise SystemExit(f" REFUS : {archive} existe deja. On n'ecrase pas une sauvegarde.") - 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)))