# machine-backup — design

Date : 2026-08-05
Statut : validé, prêt pour plan d'implémentation
Emplacement cible : `tools/machine-backup/`

## Problème

Reconstruire cette machine macOS après une panne ou un changement de poste demande aujourd'hui de rejouer de mémoire : réinstaller 195 formules Homebrew et 10 casks, retrouver la config shell, et surtout récupérer 5 bases PostgreSQL locales dont certaines contiennent des données clients. Rien n'est capturé automatiquement, il n'existe pas de `Brewfile`, et la config fish traîne déjà deux fichiers de sauvegarde manuels (`config.fish.backup`, `config.fish.bak-2026-07-06`) — symptôme du problème.

L'outil doit fournir deux choses : un backup fiable et répétable, et un chemin de réinstallation rapide qui ne détruit rien par accident.

## Décisions structurantes

| Décision | Choix | Raison |
|---|---|---|
| Périmètre | Socle extensible à modules | Le backup machine grossit toujours ; ajouter SSH, mise, dotfiles plus tard ne doit pas demander de retoucher le cœur |
| Destination | Local horodaté, remote optionnel | Deux responsabilités séparées ; le local doit marcher seul |
| Langage | Python + uv, amorcé par un bash minimal | La logique modulaire est intenable en bash ; le bash sert uniquement au bootstrap |
| Restore | Dry-run par défaut, refus des actions destructives | La machine cible est une machine de travail, pas un serveur jetable |
| Secrets | Chiffrement `age` | Les dumps contiennent des données clients ; obligatoire dès que le remote est actif |
| Contrat de module | `Protocol` Python + registre explicite | Impose structurellement le `plan_restore` à chaque nouveau module |
| Observabilité | Progression Rich en console, `debug.log` toujours complet | Un backup qui échoue sans laisser de trace exploitable ne vaut rien |

## Architecture

```
tools/machine-backup/
  bootstrap.sh              # bash POSIX, idempotent
  pyproject.toml
  src/machine_backup/
    cli.py                  # backup | list | restore | verify | doctor
    config.py               # ~/.config/machine-backup/config.toml
    manifest.py
    crypto.py               # age
    sync.py                 # étage remote optionnel
    console.py              # progression Rich, mode TTY / non-TTY
    logs.py                 # debug.log, rotation, rédaction des secrets
                            # (nommé logs.py et non logging.py : évite l'ombrage du module stdlib)
    modules/
      base.py               # Protocol + registre
      postgres.py
      homebrew.py
      shell.py
  tests/
```

### bootstrap.sh

Bash POSIX, une trentaine de lignes, **idempotent** : chaque étape teste avant d'agir.

```sh
command -v brew >/dev/null 2>&1 || <install homebrew>
command -v uv   >/dev/null 2>&1 || <install uv>
```

Sur une machine déjà équipée il n'installe rien et passe directement la main au CLI. Il affiche systématiquement ce qu'il a trouvé et ce qu'il a installé, pour que l'absence d'action soit lisible plutôt qu'inquiétante.

### Contrat de module

```python
class Module(Protocol):
    name: str
    def check(self) -> list[Problem]: ...
    def collect(self, dest: Path) -> ModuleResult: ...
    def plan_restore(self, src: Path) -> list[Action]: ...
    def restore(self, src: Path, actions: list[Action], force: bool) -> ModuleResult: ...
```

Une `Action` porte l'un de trois états :

| État | Sens | Comportement |
|---|---|---|
| `satisfied` | déjà conforme au snapshot | skip, affiché en gris |
| `additive` | à créer / installer, rien à écraser | exécuté avec `--apply` |
| `destructive` | écraserait quelque chose de différent | skip + signalé, sauf `--force <module>` |

Conséquence : le restore est relançable indéfiniment sur une machine à moitié reconstruite. Il ne refait que ce qui manque et ne touche jamais à ce qui a divergé sans demande explicite.

### Format de sortie

```
~/Backups/machine/2026-08-05T14-30-12/
  manifest.json           # EN CLAIR
  snapshot.tar.zst.age    # tout le reste, chiffré
```

Le manifest reste en clair délibérément : lister et inspecter les snapshots (date, modules, statuts, tailles, version macOS) ne doit pas exiger la clé privée. Il contient l'horodatage, la version de l'outil, le hostname, la version de macOS, et pour chaque module son statut, ses artefacts et leur hash SHA-256.

## Modules v1

### postgres

- `pg_dump -Fc` par base, hors `template0` / `template1`.
- **`pg_dumpall --globals-only`** en complément. Non négociable : les bases portent des GRANTs vers des rôles distincts (`elevaspeed`, `humperdoo`) ; sans les globals, un restore rejoue les données mais casse les permissions.
- `check()` échoue proprement si `pg_dump` est absent ou si le serveur ne répond pas.
- Au restore : base absente → `additive` ; base existante → `destructive`.

### homebrew

- `brew bundle dump --describe` → un `Brewfile` (taps, formules, casks, mas). Il n'en existe aucun aujourd'hui.
- Au restore : `brew bundle install`, **jamais `--cleanup`**. Une formule déjà installée est `satisfied`. Le restore est strictement additif.

### shell

Remplace un module `fish` qui aurait été trop étroit : la config shell cible peut changer d'un restore à l'autre.

- **collect** — sauvegarde indépendamment ce qui est présent : `~/.config/fish/` (en excluant les résidus `config.fish.backup` et `config.fish.bak-*`) et/ou `~/.zshrc`, `~/.zprofile`, `~/.zshenv`, thème et plugins oh-my-zsh. Le manifest enregistre quel shell était le shell de login.
- `fish_variables` est inclus mais marqué sensible dans le manifest.
- **restore** — option `--shell fish|zsh|both`. Si `zsh` est demandé et que le snapshot ne contient pas de config zsh, oh-my-zsh est installé propre plutôt que de faire échouer l'opération.
- Le changement de shell de login (`chsh`) est classé `destructive` : présent dans le plan, jamais appliqué sans `--force shell`.
- **Aucune traduction de config entre fish et zsh.** Les syntaxes divergent trop pour qu'une conversion automatique soit fiable, et une config shell silencieusement fausse coûte plus cher qu'une reconfiguration manuelle.

## CLI

```
machine-backup backup [--only postgres,shell] [--no-sync]
machine-backup list
machine-backup restore latest [--only …] [--shell fish|zsh|both] [--apply] [--force postgres]
machine-backup verify <snapshot>
machine-backup doctor
```

Options globales : `-v` / `-vv`, `--progress` / `--no-progress`.

`restore` sans `--apply` n'affiche que le plan et ne modifie rien.

## Observabilité

### Progression console (Rich)

Le mode d'affichage est choisi automatiquement :

- **TTY sans `-vv`** → progression live : une ligne par module avec son état, une barre par unité de travail réelle (les bases PostgreSQL dumpées une par une avec leur taille ; spinner pour `brew bundle dump`, qui est atomique), puis un tableau récapitulatif *module × statut × durée × taille*.
- **Non-TTY (cron, pipe) ou `-vv`** → logs linéaires horodatés, barres désactivées. Une barre de progression dans un fichier est illisible, et les deux rendus simultanés se corrompent mutuellement.
- `--progress` / `--no-progress` forcent le mode.

### debug.log

Principe : **la console est réglable, le fichier de log ne l'est jamais.** `-v` et `-vv` ne changent que la verbosité console ; le `debug.log` est toujours écrit intégralement en niveau DEBUG. Un backup qui échoue sans surveillance ne doit pas exiger qu'on ait pensé à `-vv` au préalable.

- Emplacement : `~/.local/state/machine-backup/logs/`, une run par fichier, rotation sur les N dernières (défaut 20). Le dossier est en `700`. Les logs ne partent **jamais** dans le snapshot.
- Chaque commande externe est tracée : `argv` exact, `cwd`, code de sortie, durée, `stdout` / `stderr` capturés — de quoi rejouer à la main la commande qui a cassé.
- **Rédaction automatique** avant écriture : mots de passe, `*_TOKEN`, `*_KEY`, clés `age`, URLs à credentials. Les logs vivent en clair sur disque, la rédaction n'est donc pas optionnelle.
- **Les variables d'environnement ne sont jamais capturées ni journalisées.**

### Invariant : l'outil ne touche pas à l'environnement

`machine-backup` ne modifie jamais l'environnement de l'utilisateur : pas d'écriture dans le `PATH`, pas d'`export` injecté dans les fichiers de démarrage du shell, pas de variable posée globalement. Les seules modifications d'environnement possibles sont celles que l'utilisateur restaure explicitement via le module `shell`, en tant que contenu de fichiers de configuration — jamais comme effet de bord de l'outil.

### doctor

Point d'entrée de diagnostic : versions et chemins résolus des binaires requis, atteignabilité de la clé privée `age`, état du serveur PostgreSQL, remote configuré, espace disque disponible, emplacement des logs.

## Flux d'un backup

1. `check()` sur les modules sélectionnés. Un prérequis manquant marque le module `skipped` avec sa raison ; **les autres continuent**.
2. `collect()` de chaque module dans `tmp/<module>/`.
3. Écriture du `manifest.json`.
4. `tar` + `zstd` + chiffrement `age` → `snapshot.tar.zst.age`. Le `manifest.json` est recopié en clair à côté.
5. Rotation : suppression des snapshots au-delà des N derniers (défaut 7, configurable).
6. `sync` vers le remote, uniquement si l'étape 4 a réussi et si un remote est configuré.

## Gestion d'erreurs

Un module qui échoue n'annule jamais les autres. Le snapshot est alors marqué `partial` dans le manifest, le CLI sort en code 1 et résume les échecs. Un snapshot partiel reste restaurable pour ce qu'il contient : deux modules sur trois valent mieux qu'aucun.

`verify` relit un snapshot, le déchiffre en mémoire et recontrôle les hash du manifest. C'est le mécanisme qui détecte une corruption silencieuse.

## Risque principal : la clé age

`age` chiffre avec une clé publique. **La clé privée doit vivre hors du backup** (gestionnaire de mots de passe, ou copie papier). Sa perte rend tous les snapshots définitivement illisibles.

C'est le mode de défaillance le plus probable de tout le système, bien avant un bug de code. `doctor` vérifie donc explicitement que la clé privée est atteignable et alerte sinon.

## Tests

Lancés via `uv run pytest`.

- `shell` et `homebrew` : testés contre des fixtures sur disque (fausse arborescence fish, `Brewfile` synthétique, faux `.zshrc`). Aucun contact avec la machine réelle.
- `postgres` : testé contre une base jetable créée et détruite par le test ; `skip` propre en l'absence de serveur local.
- Cœur (manifest, rotation, chiffrement, classification `satisfied`/`additive`/`destructive`) : testé avec un module factice, sans aucune dépendance système. C'est la zone où un bug coûte le plus cher et elle est entièrement isolable.
- Observabilité : la rédaction des secrets est testée avec des cas connus (un mot de passe injecté doit être absent du log produit) ; le basculement TTY / non-TTY est testé en forçant les deux modes.

## Hors scope

- Le CLI interactif à la racine du dépôt. Il viendra quand il y aura plusieurs outils à sélectionner.
- Les modules SSH/GPG, mise, dotfiles, defaults macOS, repos Git. L'architecture les accueille sans modification du cœur ; ils ne font pas partie de la v1.
- Toute traduction de configuration entre shells.
