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>
This commit is contained in:
Daniel Allaire 2026-10-04 14:37:07 -04:00
parent 1167cc12e0
commit 5d7d2a8918
4 changed files with 89 additions and 26 deletions

View file

@ -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=<clé>` 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-<poste>.tar`, et de même
`setops-cles-<poste>.tar[.gpg]`). La docstring promettait `setops-voutes-<date>-<poste>`,
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 (`<nom>-AAAA-MM-JJ-<poste>`), 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=<clé>` 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

View file

@ -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 <dossier des dépôts>`. **À refaire après toute écriture
`tar -xf "$(ls -t setops-voutes-*.tar | head -1)" -C <dossier des dépôts>` (la plus récente).
Chaque export porte sa date (`setops-voutes-<date>-<poste>.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-<poste>.tar.gpg les clés, chiffrées
setops-cles-<date>-<poste>.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**.

View file

@ -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 : `<nom>-<date>-<poste><ext>` (2026-10-04).
Le nom etait fixe (`<nom>-<poste>`) : 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 "<dossier ou sont clones les depots>"
tar -xf "$(ls -t setops-voutes-*.tar | head -1)" -C "<dossier ou sont clones les depots>"
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"

View file

@ -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/<support>" --nom setops-voutes-<date>
python3 scripts/exporter_voutes.py --vers "/media/<support>" # -> setops-voutes-<date>-<poste>.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)))