# Sauvegardes de la base

Mêmes placeholders que `doc/mysql.md` : `<CHEMIN_APP>`, `<BINAIRE_PHP>`.

---

## Ce que fait `db:backup`

```bash
<BINAIRE_PHP> artisan db:backup
```

1. Lit les identifiants dans `config('database.connections.*')` — une seule source de
   vérité, rien à redéclarer dans un script ou une crontab. Une connexion non MySQL est
   refusée explicitement, plutôt que de produire une archive vide qu'on croirait valide.
2. Lance `mysqldump` et compresse le flux en `.sql.gz` (jamais de fichier `.sql`
   intermédiaire sur disque).
3. **Relit l'archive** et refuse de la déclarer bonne si elle n'est pas décompressable,
   si la table attendue n'y figure pas, ou si le marqueur `Dump completed` manque
   (signe d'une coupure).
4. Purge les archives de plus de `BACKUP_KEEP_DAYS` jours, **à la racine du dossier
   seulement**.

Options utiles :

| Option | Effet |
|---|---|
| `--path=` | dossier de destination (défaut `BACKUP_PATH`, sinon `storage/app/backups`) |
| `--keep-days=` | rétention (défaut `BACKUP_KEEP_DAYS`, sinon 14) |
| `--connection=` | connexion à sauvegarder (défaut : la connexion par défaut) |
| `--verify-table=` | table dont la présence atteste que le dump est complet |
| `--skip-verify` | saute le contrôle — à n'utiliser que pour diagnostiquer |

### L'outil de dump n'a pas le même nom partout

`mysqldump` **n'est pas un nom universel** : MariaDB 11 ne le fournit plus du tout,
l'outil s'y appelle `mariadb-dump`. La commande cherche donc `mysqldump` puis
`mariadb-dump`, et le chemin peut être imposé par `--binary=` ou `BACKUP_MYSQLDUMP`.

Elle adapte aussi ses options à la variante détectée, via `<binaire> --version`. Matrice
établie sur de vrais serveurs :

| Client | Serveur | Sans adaptation |
|---|---|---|
| `mysqldump` 8.0 | MySQL 8.0 | OK |
| `mysqldump` 8.0 | MariaDB 11 | code 2 — `Unknown table 'COLUMN_STATISTICS'` |
| `mariadb-dump` 11 | MariaDB 11 | code 7 — `unknown variable 'set-gtid-purged'` |

D'où deux options réservées au client MySQL : `--set-gtid-purged=OFF` et
`--column-statistics=0` (cette dernière désactive l'interrogation de
`information_schema.COLUMN_STATISTICS`, table absente de MariaDB).

La composition de la ligne de commande est une fonction pure,
`DatabaseBackup::dumpArguments()`, couverte par `tests/Unit/DatabaseBackupArgumentsTest.php` —
donc vérifiable sans binaire ni serveur. Le dump et la restauration réels sont validés
sur les deux moteurs (cf. `doc/mysql.md`, § 0).

Choix de conception, pour ne pas les reperdre :

- **`MYSQL_PWD` et non `-p…`** : le mot de passe n'apparaît pas dans `ps`.
- **`--single-transaction`** : cohérence InnoDB sans verrouiller le site pendant le dump.
- **`--no-tablespaces`** : évite d'exiger le privilège global `PROCESS`. Le compte
  applicatif n'a besoin de droits que sur sa propre base.
- **`--default-character-set=utf8mb4`** : sans lui, les émoji des messages clients
  ressortent mutilés de la restauration.
- Pas de `--routines` / `--triggers` / `--events` : l'application n'en a aucun, et
  `--routines` exigerait `SHOW_ROUTINE`.

## Variables d'environnement

```dotenv
# Rétention en jours à la racine du dossier de sauvegarde. Le sous-dossier manual/
# n'est jamais purgé.
BACKUP_KEEP_DAYS=14

# Dossier de destination. Défaut : storage/app/backups
# BACKUP_PATH=/var/sauvegardes/mostiglass

# Destinataire des alertes techniques (sauvegarde en échec, devis non archivé).
# Sans valeur : repli sur MAIL_DOLIBARR_FAILURE_RECIPIENT.
MAIL_ALERTS_RECIPIENT=
```

Ces variables sont lues par `config/backup.php`, et non par un `env()` appelé depuis la
commande : avec une configuration en cache — ce que fait `deploy.sh` —, `env()` ne lit
plus le `.env`. **Les modifier en production n'a donc d'effet qu'après
`php artisan config:cache`**, que `deploy.sh` rejoue à chaque déploiement.

## Le sous-dossier `manual/`

`storage/app/backups/manual/` échappe à la purge. Il est destiné aux sauvegardes qu'on
veut garder indéfiniment :

- la copie SQLite d'avant bascule (`doc/mysql.md`, étape 1) ;
- un dump d'avant déploiement risqué.

```bash
<BINAIRE_PHP> artisan db:backup --path=<CHEMIN_APP>/storage/app/backups/manual
```

## Planification

`app/Console/Kernel.php` planifie `db:backup` à **03:15 Europe/Paris**, avec
`withoutOverlapping(120)` et une alerte par e-mail sur échec
(`emailOutputOnFailure(config('mail.alerts'))`). L'alerte ne passe pas par les logs
volontairement : elle doit rester fiable même si la stack de logs est mal configurée
(cf. `doc/env.md`, dette D-1).

Il manque le déclencheur, à poser **sur le serveur**. Trois formes, par ordre de
préférence.

**A. `/etc/cron.d/mostiglass` — recommandé.** Déclaratif, versionnable, et il **nomme
explicitement l'utilisateur**, ce qui règle le piège de permissions décrit plus bas.

```cron
# /etc/cron.d/mostiglass — le fichier NE DOIT PAS contenir de point dans son nom,
# et doit se terminer par une ligne vide.
* * * * * www-data cd /var/www/mostiglass && <BINAIRE_PHP> artisan schedule:run >> /var/www/mostiglass/storage/logs/schedule.log 2>&1
```

**B. Crontab de l'utilisateur applicatif**, si `cron` est installé :

```bash
sudo -u www-data crontab -e
```
```cron
* * * * * cd /var/www/mostiglass && <BINAIRE_PHP> artisan schedule:run >> /var/www/mostiglass/storage/logs/schedule.log 2>&1
```

**C. Timer systemd**, si le serveur n'a pas de cron du tout — c'est le cas relevé le
01/08/2026 sur `gocap-mostiglass-2`, où `crontab` est introuvable (cf.
`doc/suivi-chantier.md`, prérequis n° 6). Journalisation dans `journalctl`, pas de
paquet supplémentaire.

```ini
# /etc/systemd/system/mostiglass-schedule.service
[Unit]
Description=Laravel scheduler (Mostiglass)

[Service]
Type=oneshot
User=www-data
WorkingDirectory=/var/www/mostiglass
ExecStart=<BINAIRE_PHP> artisan schedule:run
```

```ini
# /etc/systemd/system/mostiglass-schedule.timer
[Unit]
Description=Lance le scheduler Laravel chaque minute

[Timer]
OnCalendar=*:0/1
AccuracySec=15s
Persistent=false

[Install]
WantedBy=timers.target
```

```bash
sudo systemctl daemon-reload
sudo systemctl enable --now mostiglass-schedule.timer
systemctl list-timers mostiglass-schedule.timer
journalctl -u mostiglass-schedule.service --since '10 min ago'
```

`Persistent=false` volontairement : rattraper les minutes manquées après un
redémarrage ne sert à rien pour un scheduler qui tourne de toute façon chaque minute.

⚠️ **Quelle que soit la forme, le déclencheur doit tourner sous l'utilisateur qui possède
les fichiers de l'app**
(celui du déploiement, ou `www-data`) — **jamais `root`**. Les fichiers produits par les
tâches planifiées héritent de l'utilisateur qui les crée :

- une archive écrite par `root` en `0640` n'est plus lisible par l'application, et la
  purge de rétention échoue silencieusement ;
- `public/sitemap.xml` écrit par `root` ne peut plus être régénéré par un autre
  utilisateur — constaté en développement, où une génération lancée depuis le conteneur
  a rendu le fichier non réinscriptible depuis l'hôte.

Vérifier après la première nuit :

```bash
ls -l <CHEMIN_APP>/storage/app/backups/ <CHEMIN_APP>/public/sitemap.xml
```

`Kernel::schedule()` était vide avant ce chantier : brancher le déclencheur ne peut donc
réveiller aucune tâche inattendue. Il n'y en a que deux, toutes deux posées ici.
Vérifier ensuite :

```bash
<BINAIRE_PHP> artisan schedule:list   # db:backup à 15 3 * * * et sitemap:generate à 15 4 * * *
<BINAIRE_PHP> artisan schedule:run    # exécute ce qui est dû maintenant
```

Le lendemain matin :

```bash
ls -lh <CHEMIN_APP>/storage/app/backups/
tail -n 50 <CHEMIN_APP>/storage/logs/schedule.log
```

---

## Restauration

**C'est la seule partie de ce document qui compte vraiment.** Une sauvegarde qu'on n'a
jamais restaurée n'est pas une sauvegarde.

### Contrôler une archive sans la restaurer

```bash
gunzip -t <CHEMIN_APP>/storage/app/backups/mostiglass-2026-08-01_031500.sql.gz && echo "gzip OK"
gunzip -c <CHEMIN_APP>/storage/app/backups/mostiglass-2026-08-01_031500.sql.gz | tail -1
# doit se terminer par : -- Dump completed on ...
```

### Exercice de restauration (à faire avant la bascule, puis une fois par trimestre)

On restaure dans une base **jetable**, jamais par-dessus la production.

```bash
ARCHIVE=<CHEMIN_APP>/storage/app/backups/mostiglass-2026-08-01_031500.sql.gz

sudo mysql -e "CREATE DATABASE mostiglass_restore CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
gunzip -c "$ARCHIVE" | sudo mysql --default-character-set=utf8mb4 mostiglass_restore

sudo mysql mostiglass_restore -e "
  SELECT COUNT(*) AS lignes, MAX(created_at) AS dernier FROM product_helper_results;
  SELECT total FROM product_helper_results ORDER BY id DESC LIMIT 3;
  SELECT COLUMN_NAME, COLUMN_TYPE FROM information_schema.COLUMNS
   WHERE TABLE_SCHEMA='mostiglass_restore' AND TABLE_NAME='product_helper_results';
"
```

Ce qu'il faut voir :

- un nombre de lignes cohérent avec la production, et une date récente ;
- des montants à **deux décimales** (`decimal(12,2)`, pas `int`) ;
- les colonnes `steps`, `user`, `products` en type `json` ;
- un message client contenant accents et émoji, relu intact.

Puis nettoyer :

```bash
sudo mysql -e "DROP DATABASE mostiglass_restore;"
```

### Restauration réelle (incident)

```bash
cd <CHEMIN_APP>
<BINAIRE_PHP> artisan down

# Filet : sauvegarder l'état actuel AVANT d'écraser quoi que ce soit.
<BINAIRE_PHP> artisan db:backup --path=<CHEMIN_APP>/storage/app/backups/manual

sudo mysql -e "DROP DATABASE mostiglass; CREATE DATABASE mostiglass CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
gunzip -c "$ARCHIVE" | sudo mysql --default-character-set=utf8mb4 mostiglass

<BINAIRE_PHP> artisan migrate --force      # rattrape les migrations postérieures à l'archive
<BINAIRE_PHP> artisan up
```

---

## Ce qui est couvert par des tests, et ce qui ne l'est pas

| Sujet | Test | S'exécute |
|---|---|---|
| contrôle d'archive (tronquée, vide, non gzip, table absente…) | `tests/Unit/SqlDumpArchiveTest.php` | partout |
| rétention, protection de `manual/` | `tests/Unit/SqlDumpArchiveTest.php` | partout |
| refus d'une connexion non MySQL, destination inutilisable | `tests/Feature/DatabaseBackupCommandTest.php` | partout |
| planification à `15 3 * * *` | `tests/Feature/DatabaseBackupCommandTest.php` | partout |
| **dump réel → restauration réelle → relecture des données** | `tests/Database/DatabaseBackupRoundTripTest.php` | **seulement si `mysqldump` est installé** |

Le dernier est le seul qui prouve qu'une archive se restaure. Il s'ignore de lui-même
sur une machine sans `mysqldump` — ce qui est le cas de l'environnement de
développement WSL actuel, où sa logique a néanmoins été vérifiée une fois en relayant
`mysqldump` vers le conteneur. **Il doit être exécuté sur le serveur** dans le cadre de
la recette de bascule :

```bash
DB_DATABASE=mostiglass_test <BINAIRE_PHP> artisan test -c phpunit.mysql.xml --testsuite Database
```

Aucun test ne peut couvrir les deux points suivants, qui restent des tâches
d'exploitation :

- **Copie hors-machine.** Une archive qui vit sur le disque qu'elle sauvegarde ne
  protège pas d'une perte du serveur. À synchroniser vers un stockage distant
  (`storage/app/backups` **et** `storage/app/public` pour les médias du blog).
- **Espace disque.** `BACKUP_KEEP_DAYS=14` borne la rétention, pas la taille. Surveiller
  `df -h` sur la partition de `BACKUP_PATH`.

---

Voir aussi : `doc/mysql.md`, `doc/env.md`.
