From 2116705572305fa92deba4adf9ed21f7a511c615 Mon Sep 17 00:00:00 2001 From: sysangels | Michal Koeckeis-Fresel Date: Thu, 5 Mar 2026 15:33:48 +0100 Subject: [PATCH 1/3] [FEATURE] Backup: Towers of Hanoi rotation strategy #3271 [FEATURE] Backup: Towers of Hanoi rotation strategy #3271 translations by claude.ai --- src/common/core/backup/README.de.md | 52 ++++-- src/common/core/backup/README.es.md | 74 ++++---- src/common/core/backup/README.fr.md | 56 ++++-- src/common/core/backup/README.md | 42 +++-- src/common/core/backup/README.zh.md | 60 +++--- src/common/core/backup/backup.py | 205 ++++++++++++++++++++- src/common/core/backup/bwcli/check.py | 42 +++++ src/common/core/backup/bwcli/save.py | 15 +- src/common/core/backup/jobs/backup-data.py | 59 ++++-- src/common/core/backup/plugin.json | 18 +- 10 files changed, 491 insertions(+), 132 deletions(-) create mode 100644 src/common/core/backup/bwcli/check.py diff --git a/src/common/core/backup/README.de.md b/src/common/core/backup/README.de.md index aba491cfea..54df50b0f3 100644 --- a/src/common/core/backup/README.de.md +++ b/src/common/core/backup/README.de.md @@ -2,30 +2,32 @@ Das Backup-Plugin bietet eine automatisierte Backup-Lösung zum Schutz Ihrer Bun **So funktioniert es:** -1. Ihre Datenbank wird automatisch gemäß dem von Ihnen festgelegten Zeitplan (täglich, wöchentlich oder monatlich) gesichert. +1. Ihre Datenbank wird automatisch gemäß dem von Ihnen festgelegten Zeitplan (`daily`, `weekly`, `monthly` oder `hanoi`) gesichert. 2. Backups werden in einem angegebenen Verzeichnis auf Ihrem System gespeichert. -3. Alte Backups werden automatisch basierend auf Ihren Aufbewahrungseinstellungen rotiert. -4. Sie können jederzeit manuell Backups erstellen, vorhandene Backups auflisten oder eine Wiederherstellung aus einem Backup durchführen. -5. Vor jeder Wiederherstellung wird der aktuelle Zustand als Sicherheitsmaßnahme automatisch gesichert. +3. Vor jedem Backup wird der verfügbare Speicherplatz geprüft. +4. Jede Backup-ZIP enthält den SQL-Dump und eine SHA-256-Prüfsummendatei (`.sha256`) zur Integritätsprüfung. +5. Alte Backups werden automatisch rotiert. Der Modus `hanoi` verwendet die Türme-von-Hanoi-Rotation (24 Dateien, ~85 Tage Abdeckung). +6. Beim Wiederherstellen wird die Prüfsumme verifiziert, bevor die aktive Datenbank verändert wird. +7. Sie können jederzeit manuell Backups erstellen, auflisten, prüfen oder wiederherstellen. +8. Vor jeder Wiederherstellung wird der aktuelle Zustand als Sicherheitsmaßnahme automatisch gesichert. ### Wie man es benutzt -Führen Sie die folgenden Schritte aus, um die Backup-Funktion zu konfigurieren und zu verwenden: - 1. **Aktivieren Sie die Funktion:** Die Backup-Funktion ist standardmäßig aktiviert. Bei Bedarf können Sie dies mit der Einstellung `USE_BACKUP` steuern. 2. **Backup-Zeitplan konfigurieren:** Wählen Sie mit dem Parameter `BACKUP_SCHEDULE`, wie oft Backups durchgeführt werden sollen. -3. **Aufbewahrungsrichtlinie festlegen:** Geben Sie mit der Einstellung `BACKUP_ROTATION` an, wie viele Backups aufbewahrt werden sollen. +3. **Aufbewahrungsrichtlinie festlegen:** Geben Sie mit der Einstellung `BACKUP_ROTATION` an, wie viele Backups aufbewahrt werden sollen (wird bei `BACKUP_SCHEDULE=hanoi` ignoriert). 4. **Speicherort festlegen:** Wählen Sie mit der Einstellung `BACKUP_DIRECTORY`, wo die Backups gespeichert werden sollen. 5. **CLI-Befehle verwenden:** Verwalten Sie Backups bei Bedarf manuell mit den `bwcli plugin backup`-Befehlen. ### Konfigurationseinstellungen -| Einstellung | Standard | Kontext | Mehrfach | Beschreibung | -| ------------------ | ---------------------------- | ------- | -------- | -------------------------------------------------------------------------------------------------------------------- | -| `USE_BACKUP` | `yes` | global | nein | **Backup aktivieren:** Auf `yes` setzen, um automatische Backups zu aktivieren. | -| `BACKUP_SCHEDULE` | `daily` | global | nein | **Backup-Frequenz:** Wie oft Backups durchgeführt werden sollen. Optionen: `daily`, `weekly` oder `monthly`. | -| `BACKUP_ROTATION` | `7` | global | nein | **Backup-Aufbewahrung:** Die Anzahl der aufzubewahrenden Backup-Dateien. Ältere Backups werden automatisch gelöscht. | -| `BACKUP_DIRECTORY` | `/var/lib/bunkerweb/backups` | global | nein | **Backup-Speicherort:** Das Verzeichnis, in dem die Backup-Dateien gespeichert werden. | +| Einstellung | Standard | Kontext | Mehrfach | Beschreibung | +| ------------------ | ---------------------------- | ------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `USE_BACKUP` | `yes` | global | nein | **Backup aktivieren:** Auf `yes` setzen, um automatische Backups zu aktivieren. | +| `BACKUP_SCHEDULE` | `daily` | global | nein | **Backup-Frequenz:** `daily`, `weekly`, `monthly` oder `hanoi` (stündlich mit Türme-von-Hanoi-Rotation, ~85 Tage Abdeckung). | +| `BACKUP_ROTATION` | `7` | global | nein | **Backup-Aufbewahrung:** Anzahl der aufzubewahrenden Backup-Dateien. Wird bei `hanoi` ignoriert (Rotation wird automatisch verwaltet). | +| `BACKUP_DIRECTORY` | `/var/lib/bunkerweb/backups` | global | nein | **Backup-Speicherort:** Das Verzeichnis, in dem die Backup-Dateien gespeichert werden. | +| `BACKUP_IGNORE_CHECKSUM_ERROR_ON_DB_RESTORE` | `no` | global | nein | **Prüfsummenfehler ignorieren:** Bei `yes` wird ein fehlgeschlagener SHA-256-Check die Wiederherstellung nicht abbrechen. Nur als letztes Mittel verwenden. | ### Befehlszeilenschnittstelle @@ -35,6 +37,9 @@ Das Backup-Plugin bietet mehrere CLI-Befehle zur Verwaltung Ihrer Backups: # Alle verfügbaren Backups auflisten bwcli plugin backup list +# SHA-256-Prüfsummen aller Backups prüfen +bwcli plugin backup check + # Ein manuelles Backup erstellen bwcli plugin backup save @@ -49,10 +54,13 @@ bwcli plugin backup restore /pfad/zum/backup/backup-sqlite-2023-08-15_12-34-56.z ``` !!! tip "Sicherheit geht vor" - Vor jeder Wiederherstellung erstellt das Backup-Plugin automatisch ein Backup des aktuellen Zustands Ihrer Datenbank an einem temporären Ort. Dies bietet eine zusätzliche Absicherung für den Fall, dass Sie die Wiederherstellung rückgängig machen müssen. + Vor jeder Wiederherstellung erstellt das Backup-Plugin automatisch ein Backup des aktuellen Zustands Ihrer Datenbank an einem temporären Ort. + +!!! info "Integritätsprüfung" + Jede ZIP enthält eine SHA-256-Prüfsummendatei. Die Prüfsumme wird automatisch vor jeder Wiederherstellung verifiziert. Verwenden Sie `bwcli plugin backup check`, um alle Backups jederzeit zu prüfen. !!! warning "Datenbankkompatibilität" - Das Backup-Plugin unterstützt SQLite, MySQL/MariaDB und PostgreSQL-Datenbanken. Oracle-Datenbanken werden derzeit für Backup- und Wiederherstellungsvorgänge nicht unterstützt. + Das Backup-Plugin unterstützt SQLite, MySQL/MariaDB und PostgreSQL-Datenbanken. Oracle-Datenbanken werden derzeit nicht unterstützt. ### Beispielkonfigurationen @@ -67,9 +75,17 @@ bwcli plugin backup restore /pfad/zum/backup/backup-sqlite-2023-08-15_12-34-56.z BACKUP_DIRECTORY: "/var/lib/bunkerweb/backups" ``` -=== "Wöchentliche Backups mit erweiterter Aufbewahrung" +=== "Türme von Hanoi (~85 Tage)" + + Stündliche Ausführung mit Türme-von-Hanoi-Rotation. Hält 24 Dateien mit ~85 Tagen Abdeckung. `BACKUP_ROTATION` wird ignoriert: + + ```yaml + USE_BACKUP: "yes" + BACKUP_SCHEDULE: "hanoi" + BACKUP_DIRECTORY: "/var/lib/bunkerweb/backups" + ``` - Konfiguration für seltenere Backups mit längerer Aufbewahrung: +=== "Wöchentliche Backups mit erweiterter Aufbewahrung" ```yaml USE_BACKUP: "yes" @@ -80,8 +96,6 @@ bwcli plugin backup restore /pfad/zum/backup/backup-sqlite-2023-08-15_12-34-56.z === "Monatliche Backups an einem benutzerdefinierten Speicherort" - Konfiguration für monatliche Backups, die an einem benutzerdefinierten Ort gespeichert werden: - ```yaml USE_BACKUP: "yes" BACKUP_SCHEDULE: "monthly" diff --git a/src/common/core/backup/README.es.md b/src/common/core/backup/README.es.md index ffc57c918e..1b7dcaa3c6 100644 --- a/src/common/core/backup/README.es.md +++ b/src/common/core/backup/README.es.md @@ -2,63 +2,69 @@ El complemento de copia de seguridad proporciona una solución de respaldo autom **Cómo funciona:** -1. Su base de datos se respalda automáticamente según el cronograma que establezca (diario, semanal o mensual). +1. Su base de datos se respalda automáticamente según el cronograma que establezca (`daily`, `weekly`, `monthly` o `hanoi`). 2. Las copias de seguridad se almacenan en un directorio específico de su sistema. -3. Las copias de seguridad antiguas se rotan automáticamente según su configuración de retención. -4. Puede crear copias de seguridad manualmente, enumerar las copias de seguridad existentes o restaurar desde una copia de seguridad en cualquier momento. -5. Antes de cualquier operación de restauración, el estado actual se respalda automáticamente como medida de seguridad. +3. Antes de cada copia de seguridad se verifica el espacio libre en disco disponible. +4. Cada ZIP contiene el volcado SQL y un archivo de suma de verificación SHA-256 (`.sha256`) para verificar la integridad. +5. Las copias de seguridad antiguas se rotan automáticamente. El modo `hanoi` aplica la rotación Torres de Hanói (24 archivos, ~85 días de cobertura). +6. Al restaurar, la suma de verificación se comprueba antes de modificar la base de datos activa. +7. Puede crear, listar, verificar y restaurar copias de seguridad manualmente en cualquier momento. +8. Antes de cualquier operación de restauración, el estado actual se respalda automáticamente como medida de seguridad. ### Cómo usar -Siga estos pasos para configurar y utilizar la función de copia de seguridad: - 1. **Habilite la función:** La función de copia de seguridad está habilitada por defecto. Si es necesario, puede controlarla con el ajuste `USE_BACKUP`. -2. **Configure el cronograma de copia de seguridad:** Elija la frecuencia con la que deben realizarse las copias de seguridad estableciendo el parámetro `BACKUP_SCHEDULE`. -3. **Establezca la política de retención:** Especifique cuántas copias de seguridad conservar utilizando el ajuste `BACKUP_ROTATION`. -4. **Defina la ubicación de almacenamiento:** Elija dónde se almacenarán las copias de seguridad utilizando el ajuste `BACKUP_DIRECTORY`. -5. **Use los comandos de la CLI:** Gestione las copias de seguridad manualmente con los comandos `bwcli plugin backup` cuando sea necesario. +2. **Configure el cronograma:** Elija la frecuencia con la que deben realizarse las copias de seguridad estableciendo el parámetro `BACKUP_SCHEDULE`. +3. **Establezca la política de retención:** Especifique cuántas copias conservar con `BACKUP_ROTATION` (se ignora si `BACKUP_SCHEDULE=hanoi`). +4. **Defina la ubicación:** Elija dónde se almacenarán las copias con `BACKUP_DIRECTORY`. +5. **Use los comandos de la CLI:** Gestione las copias manualmente con los comandos `bwcli plugin backup`. ### Ajustes de configuración -| Ajuste | Valor por defecto | Contexto | Múltiple | Descripción | -| ------------------ | ---------------------------- | -------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `USE_BACKUP` | `yes` | global | no | **Habilitar copia de seguridad:** Establezca en `yes` para habilitar las copias de seguridad automáticas. | -| `BACKUP_SCHEDULE` | `daily` | global | no | **Frecuencia de la copia de seguridad:** Con qué frecuencia se realizan las copias de seguridad. Opciones: `daily`, `weekly` o `monthly`. | -| `BACKUP_ROTATION` | `7` | global | no | **Retención de copias de seguridad:** El número de archivos de copia de seguridad que se deben conservar. Las copias de seguridad más antiguas que este número se eliminarán automáticamente. | -| `BACKUP_DIRECTORY` | `/var/lib/bunkerweb/backups` | global | no | **Ubicación de la copia de seguridad:** El directorio donde se almacenarán los archivos de copia de seguridad. | +| Ajuste | Valor por defecto | Contexto | Múltiple | Descripción | +| ------------------ | ---------------------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `USE_BACKUP` | `yes` | global | no | **Habilitar copia de seguridad:** Establezca en `yes` para habilitar las copias automáticas. | +| `BACKUP_SCHEDULE` | `daily` | global | no | **Frecuencia:** `daily`, `weekly`, `monthly` o `hanoi` (horaria con rotación Torres de Hanói, ~85 días de cobertura). | +| `BACKUP_ROTATION` | `7` | global | no | **Retención:** Número de archivos a conservar. Se ignora con `hanoi` (la rotación se gestiona automáticamente). | +| `BACKUP_DIRECTORY` | `/var/lib/bunkerweb/backups` | global | no | **Ubicación:** El directorio donde se almacenarán los archivos de copia de seguridad. | +| `BACKUP_IGNORE_CHECKSUM_ERROR_ON_DB_RESTORE` | `no` | global | no | **Ignorar error de suma de verificación:** Si `yes`, un error SHA-256 no cancelará la restauración. Úselo solo como último recurso para backups parcialmente corruptos. | ### Interfaz de línea de comandos -El complemento de copia de seguridad proporciona varios comandos de la CLI para gestionar sus copias de seguridad: - ```bash # Listar todas las copias de seguridad disponibles bwcli plugin backup list +# Verificar las sumas SHA-256 de todas las copias +bwcli plugin backup check + # Crear una copia de seguridad manual bwcli plugin backup save -# Crear una copia de seguridad en una ubicación personalizada +# Crear una copia en una ubicación personalizada bwcli plugin backup save --directory /ruta/a/ubicacion/personalizada -# Restaurar desde la copia de seguridad más reciente +# Restaurar desde la copia más reciente bwcli plugin backup restore -# Restaurar desde un archivo de copia de seguridad específico -bwcli plugin backup restore /ruta/a/copia/de/seguridad/backup-sqlite-2023-08-15_12-34-56.zip +# Restaurar desde un archivo específico +bwcli plugin backup restore /ruta/a/copia/backup-sqlite-2023-08-15_12-34-56.zip ``` !!! tip "La seguridad es lo primero" - Antes de cualquier operación de restauración, el complemento de copia de seguridad crea automáticamente una copia de seguridad del estado actual de su base de datos en una ubicación temporal. Esto proporciona una protección adicional en caso de que necesite revertir la operación de restauración. + Antes de cualquier restauración, el complemento crea automáticamente una copia del estado actual en una ubicación temporal. + +!!! info "Verificación de integridad" + Cada ZIP contiene un archivo SHA-256. La suma se verifica automáticamente antes de cualquier restauración. Use `bwcli plugin backup check` para comprobar todas las copias en cualquier momento. !!! warning "Compatibilidad de la base de datos" - El complemento de copia de seguridad es compatible con las bases de datos SQLite, MySQL/MariaDB y PostgreSQL. Las bases de datos de Oracle no son compatibles actualmente para las operaciones de copia de seguridad y restauración. + El complemento es compatible con SQLite, MySQL/MariaDB y PostgreSQL. Las bases de datos Oracle no son compatibles actualmente. ### Configuraciones de ejemplo -=== "Copias de seguridad diarias con retención de 7 días" +=== "Copias diarias con retención de 7 días" - Configuración por defecto que crea copias de seguridad diarias y conserva los 7 archivos más recientes: + Configuración por defecto: ```yaml USE_BACKUP: "yes" @@ -67,9 +73,17 @@ bwcli plugin backup restore /ruta/a/copia/de/seguridad/backup-sqlite-2023-08-15_ BACKUP_DIRECTORY: "/var/lib/bunkerweb/backups" ``` -=== "Copias de seguridad semanales con retención extendida" +=== "Torres de Hanói (~85 días)" - Configuración para copias de seguridad menos frecuentes con una retención más prolongada: + Ejecución horaria con rotación Torres de Hanói. Conserva 24 archivos con ~85 días de cobertura. `BACKUP_ROTATION` se ignora: + + ```yaml + USE_BACKUP: "yes" + BACKUP_SCHEDULE: "hanoi" + BACKUP_DIRECTORY: "/var/lib/bunkerweb/backups" + ``` + +=== "Copias semanales con retención extendida" ```yaml USE_BACKUP: "yes" @@ -78,9 +92,7 @@ bwcli plugin backup restore /ruta/a/copia/de/seguridad/backup-sqlite-2023-08-15_ BACKUP_DIRECTORY: "/var/lib/bunkerweb/backups" ``` -=== "Copias de seguridad mensuales en una ubicación personalizada" - - Configuración para copias de seguridad mensuales almacenadas en una ubicación personalizada: +=== "Copias mensuales en ubicación personalizada" ```yaml USE_BACKUP: "yes" diff --git a/src/common/core/backup/README.fr.md b/src/common/core/backup/README.fr.md index 7b5cd78411..e8bb720299 100644 --- a/src/common/core/backup/README.fr.md +++ b/src/common/core/backup/README.fr.md @@ -1,34 +1,39 @@ Le plugin Backup fournit des sauvegardes automatisées pour protéger vos données BunkerWeb. Il crée des sauvegardes régulières selon une planification définie, stockées dans un emplacement dédié, avec rotation automatique et commandes CLI pour gérer manuellement. -Comment ça marche : +**Comment ça marche :** -1. La base est sauvegardée automatiquement selon la fréquence (quotidienne, hebdomadaire, mensuelle). +1. La base est sauvegardée automatiquement selon la fréquence choisie (`daily`, `weekly`, `monthly` ou `hanoi`). 2. Les sauvegardes sont stockées dans un répertoire dédié. -3. Les anciennes sauvegardes sont supprimées selon la politique de rétention. -4. Vous pouvez créer, lister et restaurer manuellement des sauvegardes. -5. Avant toute restauration, un snapshot de l’état courant est créé automatiquement. +3. Avant chaque sauvegarde, l'espace disque disponible est vérifié. +4. Chaque ZIP contient le dump SQL et un fichier de somme de contrôle SHA-256 (`.sha256`) pour vérifier l'intégrité. +5. Les anciennes sauvegardes sont supprimées selon la politique de rétention. Le mode `hanoi` applique la rotation Tours de Hanoï (24 fichiers, ~85 jours de couverture). +6. Lors d'une restauration, la somme de contrôle est vérifiée avant toute modification de la base active. +7. Vous pouvez créer, lister, vérifier et restaurer manuellement des sauvegardes. +8. Avant toute restauration, un snapshot de l'état courant est créé automatiquement. -### Comment l’utiliser +### Comment l'utiliser -1. Activation : `USE_BACKUP` (activé par défaut). -2. Planification : `BACKUP_SCHEDULE`. -3. Rétention : `BACKUP_ROTATION`. -4. Emplacement : `BACKUP_DIRECTORY`. -5. CLI : utilisez `bwcli plugin backup`. +1. **Activation :** `USE_BACKUP` (activé par défaut). +2. **Planification :** `BACKUP_SCHEDULE`. +3. **Rétention :** `BACKUP_ROTATION` (ignoré si `BACKUP_SCHEDULE=hanoi`). +4. **Emplacement :** `BACKUP_DIRECTORY`. +5. **CLI :** utilisez `bwcli plugin backup`. ### Paramètres -| Paramètre | Défaut | Contexte | Multiple | Description | -| ------------------ | ---------------------------- | -------- | -------- | ----------------------------------------------------------------------------- | -| `USE_BACKUP` | `yes` | global | non | Activer les sauvegardes automatiques. | -| `BACKUP_SCHEDULE` | `daily` | global | non | Fréquence : `daily`, `weekly`, `monthly`. | -| `BACKUP_ROTATION` | `7` | global | non | Rétention : nombre de fichiers à conserver. Au‑delà, suppression automatique. | -| `BACKUP_DIRECTORY` | `/var/lib/bunkerweb/backups` | global | non | Répertoire de stockage des sauvegardes. | +| Paramètre | Défaut | Contexte | Multiple | Description | +| ------------------ | ---------------------------- | -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `USE_BACKUP` | `yes` | global | non | Activer les sauvegardes automatiques. | +| `BACKUP_SCHEDULE` | `daily` | global | non | Fréquence : `daily`, `weekly`, `monthly` ou `hanoi` (horaire avec rotation Tours de Hanoï, ~85 jours de couverture). | +| `BACKUP_ROTATION` | `7` | global | non | Rétention : nombre de fichiers à conserver. Ignoré pour `hanoi` (la rotation est gérée automatiquement par l'algorithme). | +| `BACKUP_DIRECTORY` | `/var/lib/bunkerweb/backups` | global | non | Répertoire de stockage des sauvegardes. | +| `BACKUP_IGNORE_CHECKSUM_ERROR_ON_DB_RESTORE` | `no` | global | non | Si `yes`, un échec de vérification SHA-256 n'interrompt pas la restauration. À utiliser en dernier recours pour les sauvegardes partiellement corrompues. | ### Interface en ligne de commande ```bash bwcli plugin backup list # Lister +bwcli plugin backup check # Vérifier les sommes de contrôle SHA-256 bwcli plugin backup save # Sauvegarde manuelle bwcli plugin backup save --directory /chemin/perso # Emplacement personnalisé bwcli plugin backup restore # Restaurer la plus récente @@ -36,14 +41,17 @@ bwcli plugin backup restore /chemin/backup-sqlite-YYYY-MM-DD_HH-MM-SS.zip # Re ``` !!! tip "Sécurité" - Avant toute restauration, un backup de l’état courant est créé automatiquement dans un emplacement temporaire. + Avant toute restauration, un backup de l'état courant est créé automatiquement dans un emplacement temporaire. + +!!! info "Vérification d'intégrité" + Chaque ZIP contient un fichier SHA-256. La somme est vérifiée automatiquement avant toute restauration. Utilisez `bwcli plugin backup check` pour vérifier tous les backups à tout moment. !!! warning "Compatibilité bases" Supporte SQLite, MySQL/MariaDB, PostgreSQL. Oracle non pris en charge pour sauvegarde/restauration. ### Exemples -=== "Quotidien, rétention 7 jours" (défaut) +=== "Quotidien, rétention 7 jours (défaut)" ```yaml USE_BACKUP: "yes" @@ -52,6 +60,16 @@ bwcli plugin backup restore /chemin/backup-sqlite-YYYY-MM-DD_HH-MM-SS.zip # Re BACKUP_DIRECTORY: "/var/lib/bunkerweb/backups" ``` +=== "Tours de Hanoï (~85 jours)" + + Exécution horaire avec rotation Tours de Hanoï. Conserve 24 fichiers couvrant ~85 jours. `BACKUP_ROTATION` est ignoré : + + ```yaml + USE_BACKUP: "yes" + BACKUP_SCHEDULE: "hanoi" + BACKUP_DIRECTORY: "/var/lib/bunkerweb/backups" + ``` + === "Hebdomadaire, rétention étendue" ```yaml diff --git a/src/common/core/backup/README.md b/src/common/core/backup/README.md index f801684a3e..30c4427505 100644 --- a/src/common/core/backup/README.md +++ b/src/common/core/backup/README.md @@ -2,11 +2,14 @@ The Backup plugin provides an automated backup solution to protect your BunkerWe **How it works:** -1. Your database is automatically backed up according to the schedule you set (daily, weekly, or monthly). +1. Your database is automatically backed up according to the schedule you set (`daily`, `weekly`, `monthly`, or `hanoi`). 2. Backups are stored in a specified directory on your system. -3. Old backups are automatically rotated based on your retention settings. -4. You can manually create backups, list existing backups, or restore from a backup at any time. -5. Before any restore operation, the current state is automatically backed up as a safety measure. +3. Before each backup a disk-space pre-flight check is performed to ensure sufficient free space. +4. Each backup ZIP contains the SQL dump and a SHA-256 checksum file (`.sha256`) for integrity verification. +5. Old backups are automatically rotated based on your retention settings. The `hanoi` schedule uses Towers of Hanoi rotation (24 files, ~85 days of coverage) instead of a simple count. +6. When restoring, the checksum is verified before the live database is touched. +7. You can manually create, list, verify, or restore backups at any time. +8. Before any restore operation, the current state is automatically backed up as a safety measure. ### How to Use @@ -14,18 +17,19 @@ Follow these steps to configure and use the Backup feature: 1. **Enable the feature:** The backup feature is enabled by default. If needed, you can control this with the `USE_BACKUP` setting. 2. **Configure backup schedule:** Choose how often backups should occur by setting the `BACKUP_SCHEDULE` parameter. -3. **Set retention policy:** Specify how many backups to keep using the `BACKUP_ROTATION` setting. +3. **Set retention policy:** Specify how many backups to keep using the `BACKUP_ROTATION` setting (ignored when `BACKUP_SCHEDULE=hanoi`). 4. **Define storage location:** Choose where backups will be stored using the `BACKUP_DIRECTORY` setting. 5. **Use CLI commands:** Manage backups manually with the `bwcli plugin backup` commands when needed. ### Configuration Settings -| Setting | Default | Context | Multiple | Description | -| ------------------ | ---------------------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------- | -| `USE_BACKUP` | `yes` | global | no | **Enable Backup:** Set to `yes` to enable automatic backups. | -| `BACKUP_SCHEDULE` | `daily` | global | no | **Backup Frequency:** How often to perform backups. Options: `daily`, `weekly`, or `monthly`. | -| `BACKUP_ROTATION` | `7` | global | no | **Backup Retention:** The number of backup files to keep. Older backups beyond this number will be automatically deleted. | -| `BACKUP_DIRECTORY` | `/var/lib/bunkerweb/backups` | global | no | **Backup Location:** The directory where backup files will be stored. | +| Setting | Default | Context | Multiple | Description | +| ------------------ | ---------------------------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `USE_BACKUP` | `yes` | global | no | **Enable Backup:** Set to `yes` to enable automatic backups. | +| `BACKUP_SCHEDULE` | `daily` | global | no | **Backup Frequency:** How often to perform backups. Options: `daily`, `weekly`, `monthly`, or `hanoi` (hourly with Towers of Hanoi rotation, ~85 days coverage). | +| `BACKUP_ROTATION` | `7` | global | no | **Backup Retention:** Number of backup files to keep. Ignored when `BACKUP_SCHEDULE=hanoi` (rotation is managed automatically by the Hanoi algorithm). | +| `BACKUP_DIRECTORY` | `/var/lib/bunkerweb/backups` | global | no | **Backup Location:** The directory where backup files will be stored. | +| `BACKUP_IGNORE_CHECKSUM_ERROR_ON_DB_RESTORE` | `no` | global | no | **Ignore checksum error on restore:** If `yes`, a failed SHA-256 checksum will not abort the restore. Use only as a last resort for partially corrupted backups. | ### Command Line Interface @@ -35,6 +39,9 @@ The Backup plugin provides several CLI commands to manage your backups: # List all available backups bwcli plugin backup list +# Verify SHA-256 checksums of all backups +bwcli plugin backup check + # Create a manual backup bwcli plugin backup save @@ -51,6 +58,9 @@ bwcli plugin backup restore /path/to/backup/backup-sqlite-2023-08-15_12-34-56.zi !!! tip "Safety First" Before any restore operation, the Backup plugin automatically creates a backup of your current database state in a temporary location. This provides an extra safeguard in case you need to revert the restore operation. +!!! info "Integrity Verification" + Every backup ZIP contains a SHA-256 checksum file alongside the SQL dump. The checksum is automatically verified before any restore. Use `bwcli plugin backup check` to verify all backups at any time. + !!! warning "Database Compatibility" The Backup plugin supports SQLite, MySQL/MariaDB, and PostgreSQL databases. Oracle databases are not currently supported for backup and restore operations. @@ -67,6 +77,16 @@ bwcli plugin backup restore /path/to/backup/backup-sqlite-2023-08-15_12-34-56.zi BACKUP_DIRECTORY: "/var/lib/bunkerweb/backups" ``` +=== "Towers of Hanoi (~85 days coverage)" + + Runs hourly and applies Towers of Hanoi rotation. Keeps 24 files covering approximately 85 days. `BACKUP_ROTATION` is ignored: + + ```yaml + USE_BACKUP: "yes" + BACKUP_SCHEDULE: "hanoi" + BACKUP_DIRECTORY: "/var/lib/bunkerweb/backups" + ``` + === "Weekly Backups with Extended Retention" Configuration for less frequent backups with longer retention: diff --git a/src/common/core/backup/README.zh.md b/src/common/core/backup/README.zh.md index 495ae9206a..d5d7f74c5d 100644 --- a/src/common/core/backup/README.zh.md +++ b/src/common/core/backup/README.zh.md @@ -2,63 +2,67 @@ **工作原理:** -1. 您的数据库会根据您设置的时间表(每日、每周或每月)自动备份。 +1. 您的数据库会根据您设置的时间表(`daily`、`weekly`、`monthly` 或 `hanoi`)自动备份。 2. 备份存储在您系统上的指定目录中。 -3. 旧的备份会根据您的保留设置自动轮换。 -4. 您可以随时手动创建备份、列出现有备份或从备份中恢复。 -5. 在任何恢复操作之前,当前状态都会自动备份作为安全措施。 +3. 每次备份前会检查可用磁盘空间。 +4. 每个备份 ZIP 包含 SQL 转储文件和 SHA-256 校验和文件(`.sha256`),用于完整性验证。 +5. 旧备份会自动轮换。`hanoi` 模式采用汉诺塔轮换算法(保留 24 个文件,覆盖约 85 天)。 +6. 恢复时,在修改活动数据库之前会自动验证校验和。 +7. 您可以随时手动创建、列出、验证或从备份中恢复。 +8. 在任何恢复操作之前,当前状态都会自动备份作为安全措施。 ### 如何使用 -请按照以下步骤配置和使用备份功能: - 1. **启用该功能:** 备份功能默认启用。如果需要,您可以使用 `USE_BACKUP` 设置来控制此功能。 2. **配置备份计划:** 通过设置 `BACKUP_SCHEDULE` 参数选择备份的频率。 -3. **设置保留策略:** 使用 `BACKUP_ROTATION` 设置指定要保留的备份数量。 +3. **设置保留策略:** 使用 `BACKUP_ROTATION` 设置指定要保留的备份数量(`BACKUP_SCHEDULE=hanoi` 时忽略此设置)。 4. **定义存储位置:** 使用 `BACKUP_DIRECTORY` 设置选择备份的存储位置。 5. **使用 CLI 命令:** 需要时,使用 `bwcli plugin backup` 命令手动管理备份。 ### 配置设置 -| 设置 | 默认值 | 上下文 | 多个 | 描述 | -| ------------------ | ---------------------------- | ------ | ---- | --------------------------------------------------------------------- | -| `USE_BACKUP` | `yes` | 全局 | 否 | **启用备份:** 设置为 `yes` 以启用自动备份。 | -| `BACKUP_SCHEDULE` | `daily` | 全局 | 否 | **备份频率:** 执行备份的频率。选项:`daily`、`weekly` 或 `monthly`。 | -| `BACKUP_ROTATION` | `7` | 全局 | 否 | **备份保留:** 要保留的备份文件数量。超过此数量的旧备份将被自动删除。 | -| `BACKUP_DIRECTORY` | `/var/lib/bunkerweb/backups` | 全局 | 否 | **备份位置:** 备份文件将存储的目录。 | +| 设置 | 默认值 | 上下文 | 多个 | 描述 | +| ------------------ | ---------------------------- | ------ | ---- | ------------------------------------------------------------------------------------------------------------- | +| `USE_BACKUP` | `yes` | 全局 | 否 | **启用备份:** 设置为 `yes` 以启用自动备份。 | +| `BACKUP_SCHEDULE` | `daily` | 全局 | 否 | **备份频率:** `daily`、`weekly`、`monthly` 或 `hanoi`(每小时执行,采用汉诺塔轮换,覆盖约 85 天)。 | +| `BACKUP_ROTATION` | `7` | 全局 | 否 | **备份保留:** 要保留的备份文件数量。使用 `hanoi` 时忽略(轮换由算法自动管理)。 | +| `BACKUP_DIRECTORY` | `/var/lib/bunkerweb/backups` | 全局 | 否 | **备份位置:** 备份文件将存储的目录。 | +| `BACKUP_IGNORE_CHECKSUM_ERROR_ON_DB_RESTORE` | `no` | 全局 | 否 | **忽略恢复时的校验和错误:** 设为 `yes` 时,SHA-256 校验失败不会中止恢复操作。仅在备份文件部分损坏时作为最后手段使用。 | ### 命令行界面 -备份插件提供了几个 CLI 命令来管理您的备份: - ```bash # 列出所有可用的备份 bwcli plugin backup list +# 验证所有备份的 SHA-256 校验和 +bwcli plugin backup check + # 创建一个手动备份 bwcli plugin backup save -# 在自定义位置创建一个备份 +# 在自定义位置创建备份 bwcli plugin backup save --directory /path/to/custom/location # 从最近的备份恢复 bwcli plugin backup restore -# 从特定的备份文件恢复 +# 从特定备份文件恢复 bwcli plugin backup restore /path/to/backup/backup-sqlite-2023-08-15_12-34-56.zip ``` !!! tip "安全第一" - 在任何恢复操作之前,备份插件会自动在临时位置创建您当前数据库状态的备份。如果您需要还原恢复操作,这提供了一个额外的保障。 + 在任何恢复操作之前,备份插件会自动在临时位置创建当前数据库状态的备份。 + +!!! info "完整性验证" + 每个 ZIP 包含一个 SHA-256 校验和文件。在任何恢复操作之前会自动验证校验和。使用 `bwcli plugin backup check` 随时检查所有备份。 !!! warning "数据库兼容性" 备份插件支持 SQLite、MySQL/MariaDB 和 PostgreSQL 数据库。目前不支持 Oracle 数据库的备份和恢复操作。 ### 示例配置 -=== "每日备份,保留 7 天" - - 默认配置,创建每日备份并保留最近的 7 个文件: +=== "每日备份,保留 7 天(默认)" ```yaml USE_BACKUP: "yes" @@ -67,9 +71,17 @@ bwcli plugin backup restore /path/to/backup/backup-sqlite-2023-08-15_12-34-56.zi BACKUP_DIRECTORY: "/var/lib/bunkerweb/backups" ``` -=== "每周备份,延长保留期" +=== "汉诺塔轮换(约 85 天)" + + 每小时执行,采用汉诺塔轮换算法。保留 24 个文件,覆盖约 85 天。`BACKUP_ROTATION` 被忽略: - 用于频率较低但保留时间较长的备份的配置: + ```yaml + USE_BACKUP: "yes" + BACKUP_SCHEDULE: "hanoi" + BACKUP_DIRECTORY: "/var/lib/bunkerweb/backups" + ``` + +=== "每周备份,延长保留期" ```yaml USE_BACKUP: "yes" @@ -80,8 +92,6 @@ bwcli plugin backup restore /path/to/backup/backup-sqlite-2023-08-15_12-34-56.zi === "每月备份到自定义位置" - 用于每月备份并存储在自定义位置的配置: - ```yaml USE_BACKUP: "yes" BACKUP_SCHEDULE: "monthly" diff --git a/src/common/core/backup/backup.py b/src/common/core/backup/backup.py index e896745d34..37c560bd7d 100644 --- a/src/common/core/backup/backup.py +++ b/src/common/core/backup/backup.py @@ -1,16 +1,17 @@ #!/usr/bin/env python3 -from datetime import datetime +from datetime import datetime, timedelta +from hashlib import sha256 import re from json import dumps, loads from os import getenv from os.path import join, sep from pathlib import Path from subprocess import PIPE, run -from shutil import which +from shutil import disk_usage, which from sys import exit as sys_exit, path as sys_path from time import sleep -from typing import Literal +from typing import Callable, List, Literal, Optional, Tuple from zipfile import ZIP_DEFLATED, ZipFile for deps_path in [join(sep, "usr", "share", "bunkerweb", *paths) for paths in (("deps", "python"), ("utils",), ("db",))]: @@ -40,6 +41,129 @@ def acquire_db_lock(): DB_LOCK_FILE.touch() +def is_sqlite_wal(db: "Database") -> bool: + """Return True if the database is SQLite running in WAL journal mode. + + In WAL mode SQLite allows concurrent readers alongside an active writer, + so the backup dump can proceed without acquiring the BunkerWeb lock. + """ + database_url = make_url(db.database_uri) + if database_url.drivername.split("+")[0] != "sqlite": + return False + try: + from sqlalchemy import text # type: ignore[import] # path set via sys_path at runtime + + with db.sql_engine.connect() as conn: + row = conn.execute(text("PRAGMA journal_mode")).fetchone() + return bool(row and str(row[0]).lower() == "wal") + except Exception as exc: + LOGGER.debug(f"Could not check SQLite journal mode (assuming non-WAL): {exc}") + return False + + +def check_disk_space(database: str, db: "Database", backup_dir: Path, db_path: Optional[Path] = None) -> bool: + """Check whether backup_dir has enough free space to hold a new backup. + + The estimated dump size uses a 2× multiplier over the raw database size to + account for SQL text being larger than the on-disk binary representation + (especially for SQLite). After zip compression the actual backup file will + be significantly smaller, so this is intentionally conservative. + + Returns True when there is enough space or when the check cannot be + performed (so the backup is never silently skipped due to a query error). + """ + try: + from sqlalchemy import text # type: ignore[import] # path set via sys_path at runtime + + if database == "sqlite": + if db_path is None or not db_path.exists(): + LOGGER.warning("Cannot check disk space: SQLite database path is not available.") + return True + db_size = db_path.stat().st_size + elif database in ("mariadb", "mysql"): + with db.sql_engine.connect() as conn: + row = conn.execute(text("SELECT SUM(data_length + index_length) FROM information_schema.tables WHERE table_schema = DATABASE()")).fetchone() + db_size = int(row[0]) if row and row[0] else 0 + elif database == "postgresql": + with db.sql_engine.connect() as conn: + row = conn.execute(text("SELECT pg_database_size(current_database())")).fetchone() + db_size = int(row[0]) if row and row[0] else 0 + else: + return True # unsupported engine, skip check + + # SQL text dumps are typically 2–3× the raw binary DB size. Use 2× as a + # conservative lower bound for the uncompressed dump that will be held + # in memory (proc.stdout) before compression. + estimated_size = db_size * 2 + free = disk_usage(backup_dir).free + + LOGGER.debug(f"Disk space check: DB ~{db_size:,} B, estimated dump ~{estimated_size:,} B, free in {backup_dir} ~{free:,} B") + + if free < estimated_size: + LOGGER.error( + f"Not enough disk space for backup: need ~{estimated_size:,} bytes " + f"(2× DB size of {db_size:,} B), but only {free:,} bytes free in {backup_dir}" + ) + return False + + return True + except Exception as exc: + # Common expected causes: DB user lacks SELECT on information_schema / + # pg_database, or the connection is temporarily unavailable. + # In all cases we skip the check and let the backup proceed — a failed + # space check must never prevent a backup from running. + exc_str = str(exc).lower() + if any(kw in exc_str for kw in ("access denied", "permission denied", "insufficient privilege", "connection", "refused", "timeout")): + LOGGER.debug(f"Disk space check skipped (DB user may lack access to system tables): {exc}") + else: + LOGGER.warning(f"Disk space check skipped (unexpected error): {exc}") + return True + + +HANOI_TIERS: List[Tuple[int, timedelta]] = [ + (6, timedelta(hours=1)), # 6 × 1h + (2, timedelta(hours=2)), # 2 × 2h + (2, timedelta(hours=4)), # 2 × 4h + (2, timedelta(hours=8)), # 2 × 8h + (2, timedelta(hours=16)), # 2 × 16h + (2, timedelta(hours=32)), # 2 × 32h + (2, timedelta(hours=64)), # 2 × 64h + (2, timedelta(hours=128)), # 2 × 128h + (2, timedelta(hours=256)), # 2 × 256h (~10.7 days ago) + (2, timedelta(hours=512)), # 2 × 512h (~21–42 days ago) +] + + +def hanoi_rotation(backup_files: List[Path], now: datetime) -> List[Path]: + """Return the list of files to DELETE under Towers-of-Hanoi rotation. + + Walks backwards from *now* through the tier slots. For each slot the most + recent backup that falls within the slot's window is kept. Everything else + is returned for deletion. Total: 24 files kept, ~85 days of coverage. + """ + timed: List[Tuple[datetime, Path]] = [] + for f in backup_files: + m = re.search(r"backup-\w+-(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})", f.name) + if m: + dt = datetime.strptime(m.group(1), "%Y-%m-%d_%H-%M-%S").astimezone() + timed.append((dt, f)) + + timed.sort(reverse=True) # newest first + + keep: set = set() + cursor = now + for count, interval in HANOI_TIERS: + for _ in range(count): + slot_start = cursor - interval + for dt, f in timed: + if slot_start < dt <= cursor: + keep.add(f) + break + cursor = slot_start + + return [f for _, f in timed if f not in keep] + + def update_cache_file(db: Database, backup_dir: Path) -> str: """Update the cache file in the database.""" backup_data = loads(db.get_job_cache_file("backup-data", "backup.json") or "{}") @@ -56,7 +180,7 @@ def update_cache_file(db: Database, backup_dir: Path) -> str: return "" -def backup_database(current_time: datetime, db: Database = None, backup_dir: Path = BACKUP_DIR): +def backup_database(current_time: datetime, db: Database = None, backup_dir: Path = BACKUP_DIR, post_dump_hook: Optional[Callable] = None): """Backup the database.""" db = db or Database(LOGGER) @@ -67,6 +191,11 @@ def backup_database(current_time: datetime, db: Database = None, backup_dir: Pat stderr = "Table 'db.test_" current_time = datetime.now().astimezone() + # Pre-flight: ensure there is enough free space before starting the dump. + db_path_for_check = Path(database_url.database) if database == "sqlite" else None + if not check_disk_space(database, db, backup_dir, db_path=db_path_for_check): + sys_exit(1) + # Get table names from the SQLAlchemy model model_tables = list(Base.metadata.tables.keys()) LOGGER.info(f"Backing up {len(model_tables)} tables defined in the model") @@ -194,18 +323,79 @@ def backup_database(current_time: datetime, db: Database = None, backup_dir: Pat LOGGER.error("Failed to dump the database: Timeout reached") sys_exit(1) + dump_size = len(proc.stdout) if proc.stdout else 0 + LOGGER.debug(f"Dump complete: {dump_size} bytes. Running post-dump hook ...") + + if post_dump_hook: + # Release the DB lock here — after the dump but before compression. + # Compression (zip) is CPU-bound and does not access the database, so + # there is no need to hold the lock during it. This reduces the lock + # hold time from the full dump+compress duration (e.g. ~18 s for a + # large SQLite database) down to just the dump phase (~2 s), allowing + # other processes (e.g. concurrent bwcli calls) to proceed sooner. + post_dump_hook() + + LOGGER.info(f"Database dump complete ({dump_size} bytes), compressing ...") + LOGGER.debug("Compressing ...") + + sql_name = backup_file.with_suffix(".sql").name + LOGGER.info(f"Computing SHA-256 checksum of dump ({dump_size} bytes) ...") + checksum = sha256(proc.stdout).hexdigest() + LOGGER.info(f"SHA-256 of dump: {checksum}") + with ZipFile(backup_file, "w", compression=ZIP_DEFLATED) as zipf: - zipf.writestr(backup_file.with_suffix(".sql").name, proc.stdout) + zipf.writestr(sql_name, proc.stdout) + zipf.writestr(sql_name + ".sha256", f"{checksum} {sql_name}\n") backup_file.chmod(0o600) - - LOGGER.info(f"💾 Backup {backup_file.name} created successfully in {backup_dir}") + compressed_size = backup_file.stat().st_size + LOGGER.info(f"💾 Backup {backup_file.name} created successfully in {backup_dir} ({compressed_size:,} bytes)") return db, backup_file +def verify_backup_checksum(backup_file: Path) -> bool: + """Verify the SHA-256 checksum of the SQL dump inside a backup zip. + + Returns True if the checksum matches or if no .sha256 file is present + (backwards compatibility with backups created before checksums were added). + Returns False if the checksum does not match. + """ + sql_name = backup_file.with_suffix(".sql").name + sha256_name = sql_name + ".sha256" + + with ZipFile(backup_file, "r") as zipf: + if sha256_name not in zipf.namelist(): + LOGGER.warning(f"{backup_file.name}: no checksum file found (backup predates checksum support), skipping verification.") + return True + + stored_line = zipf.read(sha256_name).decode().strip() + stored_checksum = stored_line.split()[0] if stored_line else "" + sql_data = zipf.read(sql_name) + + computed_checksum = sha256(sql_data).hexdigest() + + if computed_checksum != stored_checksum: + LOGGER.error(f"{backup_file.name}: checksum MISMATCH — stored={stored_checksum}, computed={computed_checksum}") + return False + + LOGGER.info(f"{backup_file.name}: checksum OK ({computed_checksum})") + return True + + def restore_database(backup_file: Path, db: Database = None) -> Database: """Restore the database from a backup.""" db = db or Database(LOGGER) + + # Verify checksum before touching the live database. + LOGGER.info(f"Verifying checksum of {backup_file.name} ...") + if not verify_backup_checksum(backup_file): + if getenv("BACKUP_IGNORE_CHECKSUM_ERROR_ON_DB_RESTORE", "no") == "yes": + LOGGER.warning("Checksum verification failed but BACKUP_IGNORE_CHECKSUM_ERROR_ON_DB_RESTORE=yes — proceeding with restore anyway.") + else: + LOGGER.error("Aborting restore: checksum verification failed.") + LOGGER.error("To force restore despite a checksum mismatch, set BACKUP_IGNORE_CHECKSUM_ERROR_ON_DB_RESTORE=yes (use only as a last resort).") + sys_exit(1) + Base.metadata.drop_all(db.sql_engine) database_url = make_url(db.database_uri) database: Literal["sqlite", "mariadb", "mysql", "postgresql", "oracle"] = database_url.drivername.split("+")[0] @@ -234,6 +424,7 @@ def restore_database(backup_file: Path, db: Database = None) -> Database: env={"PATH": getenv("PATH", ""), "PYTHONPATH": getenv("PYTHONPATH", "")}, ) tmp_file.unlink(missing_ok=True) + Path(str(tmp_file) + ".sha256").unlink(missing_ok=True) else: url = make_url(db.database_uri) db_user = url.username or "" diff --git a/src/common/core/backup/bwcli/check.py b/src/common/core/backup/bwcli/check.py new file mode 100644 index 0000000000..f85873ba42 --- /dev/null +++ b/src/common/core/backup/bwcli/check.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 + +from os.path import join, sep +from sys import exit as sys_exit, path as sys_path + +deps_path = join(sep, "usr", "share", "bunkerweb", "core", "backup") +if deps_path not in sys_path: + sys_path.append(deps_path) + +from backup import BACKUP_DIR, LOGGER, verify_backup_checksum + +status = 0 + +try: + backups = sorted(BACKUP_DIR.glob("backup-*.zip")) + + if not backups: + LOGGER.info(f"No backup files found in {BACKUP_DIR}") + sys_exit(0) + + LOGGER.info(f"Checking {len(backups)} backup file(s) in {BACKUP_DIR} ...") + + failed = 0 + for backup_file in backups: + if verify_backup_checksum(backup_file): + LOGGER.info(f"✅ {backup_file.name}: OK") + else: + LOGGER.error(f"❌ {backup_file.name}: CHECKSUM MISMATCH") + failed += 1 + + if failed: + LOGGER.error(f"{failed}/{len(backups)} backup(s) failed checksum verification.") + sys_exit(1) + + LOGGER.info(f"All {len(backups)} backup(s) passed checksum verification.") +except SystemExit as se: + status = se.code +except BaseException as e: + LOGGER.error(f"Error while executing backup check command: {e}") + status = 1 + +sys_exit(status) diff --git a/src/common/core/backup/bwcli/save.py b/src/common/core/backup/bwcli/save.py index dd6e1a7dbf..4c0e0ab0d4 100644 --- a/src/common/core/backup/bwcli/save.py +++ b/src/common/core/backup/bwcli/save.py @@ -10,13 +10,12 @@ if deps_path not in sys_path: sys_path.append(deps_path) -from backup import acquire_db_lock, backup_database, BACKUP_DIR, DB_LOCK_FILE, LOGGER, update_cache_file +from backup import acquire_db_lock, backup_database, BACKUP_DIR, DB_LOCK_FILE, is_sqlite_wal, LOGGER, update_cache_file +from Database import Database # type: ignore # path added by backup module import status = 0 try: - acquire_db_lock() - # Global parser parser = ArgumentParser(description="BunkerWeb's backup plugin save command line interface") @@ -44,7 +43,15 @@ LOGGER.info(f"Creating directory {directory} as it does not exist") directory.mkdir(parents=True, exist_ok=True) - db, _ = backup_database(datetime.now().astimezone(), backup_dir=directory) + db = Database(LOGGER) + + wal = is_sqlite_wal(db) + if wal: + LOGGER.info("SQLite WAL mode detected — running non-blocking dump (no database lock required).") + db, _ = backup_database(datetime.now().astimezone(), db=db, backup_dir=directory) + else: + acquire_db_lock() + db, _ = backup_database(datetime.now().astimezone(), db=db, backup_dir=directory, post_dump_hook=DB_LOCK_FILE.unlink) if directory == BACKUP_DIR: update_cache_file(db, directory) diff --git a/src/common/core/backup/jobs/backup-data.py b/src/common/core/backup/jobs/backup-data.py index 1659d2b1b6..82acde31de 100644 --- a/src/common/core/backup/jobs/backup-data.py +++ b/src/common/core/backup/jobs/backup-data.py @@ -15,19 +15,18 @@ from Database import Database # type: ignore from logger import getLogger # type: ignore from jobs import Job # type: ignore -from backup import backup_database, update_cache_file, acquire_db_lock, DB_LOCK_FILE +from backup import backup_database, hanoi_rotation, update_cache_file, acquire_db_lock, is_sqlite_wal, DB_LOCK_FILE LOGGER = getLogger("BACKUP") status = 0 try: - # Prevent concurrent DB access with other backup plugins - acquire_db_lock() backup_dir = Path(getenv("BACKUP_DIRECTORY", "/var/lib/bunkerweb/backups")) backup_dir.mkdir(parents=True, exist_ok=True) force_backup = getenv("FORCE_BACKUP", "no") == "yes" current_time = datetime.now().astimezone() + LOGGER.debug(f"Backup directory: {backup_dir}, force_backup={force_backup}, current_time={current_time.isoformat()}") if not force_backup: # Check if backup is activated @@ -41,16 +40,20 @@ last_backup_date = last_backup.get("date", None) if last_backup_date: last_backup_date = datetime.fromisoformat(last_backup_date).astimezone() + LOGGER.debug(f"Last backup date: {last_backup_date.isoformat() if last_backup_date else 'none'}") backup_period = getenv("BACKUP_SCHEDULE", "daily") PERIOD_STAMPS = { - "daily": timedelta(days=1).total_seconds(), - "weekly": timedelta(weeks=1).total_seconds(), + "hanoi": timedelta(hours=1).total_seconds(), + "daily": timedelta(days=1).total_seconds(), + "weekly": timedelta(weeks=1).total_seconds(), "monthly": timedelta(weeks=4).total_seconds(), } - already_done = last_backup_date and last_backup_date.timestamp() + PERIOD_STAMPS[backup_period] > current_time.timestamp() + period_seconds = PERIOD_STAMPS.get(backup_period, PERIOD_STAMPS["daily"]) + already_done = last_backup_date and last_backup_date.timestamp() + period_seconds > current_time.timestamp() backup_rotation = int(getenv("BACKUP_ROTATION", "7")) + LOGGER.debug(f"Schedule: {backup_period}, period: {period_seconds}s, already_done={already_done}, rotation={backup_rotation}") sorted_files = [] if already_done: @@ -60,11 +63,14 @@ # Sort the backup files by name sorted_files = sorted(backup_files) + LOGGER.debug(f"Existing backup files ({len(sorted_files)}): {[f.name for f in sorted_files]}") - if len(sorted_files) <= backup_rotation and already_done: - LOGGER.info(f"Backup already done within the last {backup_period} period, skipping backup ...") - sys_exit(0) + if already_done: + if backup_period == "hanoi" or len(sorted_files) <= backup_rotation: + LOGGER.info(f"Backup already done within the last {backup_period} period, skipping backup ...") + sys_exit(0) + if not force_backup: db = JOB.db else: db = Database(LOGGER, sqlalchemy_string=getenv("DATABASE_URI")) @@ -78,8 +84,28 @@ LOGGER.info("First start of the scheduler, skipping backup ...") sys_exit(0) - db, _ = backup_database(current_time, db, backup_dir) + wal = is_sqlite_wal(db) + if wal: + # SQLite WAL mode: readers and writers do not block each other, so + # the dump can run without holding the BunkerWeb lock at all. + LOGGER.info("SQLite WAL mode detected — running non-blocking dump (no database lock required).") + LOGGER.debug("Starting database dump ...") + db, _ = backup_database(current_time, db, backup_dir) + else: + # Acquire the lock only when a backup will actually run. + # Placing it here (rather than at script start) means that + # rotation-only runs and already_done early exits never touch + # the lock file. Combined with the post_dump_hook that releases + # the lock after the SQL dump (before compression), the total + # lock hold time is reduced to just the dump phase (~2 s for a + # typical SQLite database, down from ~18 s with the old approach). + LOGGER.debug("Acquiring database lock ...") + acquire_db_lock() + LOGGER.debug("Database lock acquired.") + LOGGER.debug("Starting database dump ...") + db, _ = backup_database(current_time, db, backup_dir, post_dump_hook=DB_LOCK_FILE.unlink) backed_up = True + LOGGER.debug("Database dump and compression complete.") if not force_backup: # Get all backup files in the directory @@ -87,12 +113,21 @@ # Sort the backup files by name sorted_files = sorted(backup_files) + LOGGER.debug(f"Backup files after dump ({len(sorted_files)}): {[f.name for f in sorted_files]}") if not force_backup: - # Check if the number of backup files exceeds the rotation limit - if len(sorted_files) > backup_rotation: + if backup_period == "hanoi": + backup_directory = Path(getenv("BACKUP_DIRECTORY", "/var/lib/bunkerweb/backups")) + all_files = sorted(backup_directory.glob("backup-*.zip")) + to_delete = hanoi_rotation(all_files, current_time) + LOGGER.debug(f"Hanoi rotation: {len(all_files)} files total, {len(to_delete)} to delete, {len(all_files) - len(to_delete)} to keep.") + for file in to_delete: + LOGGER.warning(f"Removing old backup file: {file.name} (Towers of Hanoi rotation).") + file.unlink() + elif len(sorted_files) > backup_rotation: # Calculate the number of files to remove num_files_to_remove = len(sorted_files) - backup_rotation + LOGGER.debug(f"Rotation: removing {num_files_to_remove} file(s) to stay within limit of {backup_rotation}.") # Remove the oldest backup files for file in sorted_files[:num_files_to_remove]: diff --git a/src/common/core/backup/plugin.json b/src/common/core/backup/plugin.json index 96dfd8555b..5ac69f2f76 100644 --- a/src/common/core/backup/plugin.json +++ b/src/common/core/backup/plugin.json @@ -17,12 +17,12 @@ "BACKUP_SCHEDULE": { "context": "global", "default": "daily", - "help": "The frequency of the backup (daily, weekly or monthly)", + "help": "The frequency of the backup. 'hanoi' runs hourly and applies Towers of Hanoi rotation (24 files, ~85 days coverage).", "id": "backup-schedule", "label": "Backup schedule", - "regex": "^(daily|weekly|monthly)$", + "regex": "^(daily|weekly|monthly|hanoi)$", "type": "select", - "select": ["daily", "weekly", "monthly"] + "select": ["daily", "weekly", "monthly", "hanoi"] }, "BACKUP_ROTATION": { "context": "global", @@ -41,18 +41,28 @@ "label": "Backup directory", "regex": "^.*$", "type": "text" + }, + "BACKUP_IGNORE_CHECKSUM_ERROR_ON_DB_RESTORE": { + "context": "global", + "default": "no", + "help": "If set to 'yes', a failed SHA-256 checksum verification will not abort the restore. Use only as a last resort when the backup file is partially corrupted but you still need to recover data.", + "id": "backup-ignore-checksum-error-on-db-restore", + "label": "Ignore checksum error on restore", + "regex": "^(yes|no)$", + "type": "check" } }, "jobs": [ { "name": "backup-data", "file": "backup-data.py", - "every": "day", + "every": "hour", "reload": false, "async": true } ], "bwcli": { + "check": "check.py", "list": "list.py", "save": "save.py", "restore": "restore.py" From ac8d09b79393f59d859ee5eb50dcb842ac99868d Mon Sep 17 00:00:00 2001 From: sysangels | Michal Koeckeis-Fresel Date: Fri, 6 Mar 2026 17:47:32 +0100 Subject: [PATCH 2/3] Copilot fixes --- src/common/core/backup/backup.py | 38 +++++----- src/common/core/backup/bwcli/save.py | 17 +++-- src/common/core/backup/jobs/backup-data.py | 40 +++++----- tests/core/backup/main.py | 86 +++++++++++++++++++++- 4 files changed, 133 insertions(+), 48 deletions(-) diff --git a/src/common/core/backup/backup.py b/src/common/core/backup/backup.py index 37c560bd7d..532be42c22 100644 --- a/src/common/core/backup/backup.py +++ b/src/common/core/backup/backup.py @@ -121,16 +121,16 @@ def check_disk_space(database: str, db: "Database", backup_dir: Path, db_path: O HANOI_TIERS: List[Tuple[int, timedelta]] = [ - (6, timedelta(hours=1)), # 6 × 1h - (2, timedelta(hours=2)), # 2 × 2h - (2, timedelta(hours=4)), # 2 × 4h - (2, timedelta(hours=8)), # 2 × 8h - (2, timedelta(hours=16)), # 2 × 16h - (2, timedelta(hours=32)), # 2 × 32h - (2, timedelta(hours=64)), # 2 × 64h - (2, timedelta(hours=128)), # 2 × 128h - (2, timedelta(hours=256)), # 2 × 256h (~10.7 days ago) - (2, timedelta(hours=512)), # 2 × 512h (~21–42 days ago) + (6, timedelta(hours=1)), # 6 × 1h → covers last 0–6h ago + (2, timedelta(hours=2)), # 2 × 2h → covers 8+10h ago + (2, timedelta(hours=4)), # 2 × 4h → covers 14+18h ago + (2, timedelta(hours=8)), # 2 × 8h → covers 26+34h ago (~1.1+1.4d) + (2, timedelta(hours=16)), # 2 × 16h → covers 50+66h ago (~2.1+2.75d) + (2, timedelta(hours=32)), # 2 × 32h → covers 98+130h ago (~4.1+5.4d) + (2, timedelta(hours=64)), # 2 × 64h → covers 194+258h ago (~8.1+10.75d) + (2, timedelta(hours=128)), # 2 × 128h → covers 386+514h ago (~16.1+21.4d) + (2, timedelta(hours=256)), # 2 × 256h → covers 770+1026h ago (~32.1+42.75d) + (2, timedelta(hours=512)), # 2 × 512h → covers 1538+2050h ago (~64.1+85.4d) ] @@ -363,14 +363,18 @@ def verify_backup_checksum(backup_file: Path) -> bool: sql_name = backup_file.with_suffix(".sql").name sha256_name = sql_name + ".sha256" - with ZipFile(backup_file, "r") as zipf: - if sha256_name not in zipf.namelist(): - LOGGER.warning(f"{backup_file.name}: no checksum file found (backup predates checksum support), skipping verification.") - return True + try: + with ZipFile(backup_file, "r") as zipf: + if sha256_name not in zipf.namelist(): + LOGGER.warning(f"{backup_file.name}: no checksum file found (backup predates checksum support), skipping verification.") + return True - stored_line = zipf.read(sha256_name).decode().strip() - stored_checksum = stored_line.split()[0] if stored_line else "" - sql_data = zipf.read(sql_name) + stored_line = zipf.read(sha256_name).decode().strip() + stored_checksum = stored_line.split()[0] if stored_line else "" + sql_data = zipf.read(sql_name) + except Exception as exc: + LOGGER.error(f"{backup_file.name}: could not read backup archive: {exc}") + return False computed_checksum = sha256(sql_data).hexdigest() diff --git a/src/common/core/backup/bwcli/save.py b/src/common/core/backup/bwcli/save.py index 4c0e0ab0d4..3bbdf97174 100644 --- a/src/common/core/backup/bwcli/save.py +++ b/src/common/core/backup/bwcli/save.py @@ -14,6 +14,7 @@ from Database import Database # type: ignore # path added by backup module import status = 0 +lock_acquired = False try: # Global parser @@ -45,13 +46,12 @@ db = Database(LOGGER) - wal = is_sqlite_wal(db) - if wal: - LOGGER.info("SQLite WAL mode detected — running non-blocking dump (no database lock required).") - db, _ = backup_database(datetime.now().astimezone(), db=db, backup_dir=directory) - else: - acquire_db_lock() - db, _ = backup_database(datetime.now().astimezone(), db=db, backup_dir=directory, post_dump_hook=DB_LOCK_FILE.unlink) + if is_sqlite_wal(db): + LOGGER.info("SQLite WAL mode detected — SQLite readers/writers won't block, but the BunkerWeb lock is still acquired to prevent concurrent backup/restore operations.") + + acquire_db_lock() + lock_acquired = True + db, _ = backup_database(datetime.now().astimezone(), db=db, backup_dir=directory, post_dump_hook=lambda: DB_LOCK_FILE.unlink(missing_ok=True)) if directory == BACKUP_DIR: update_cache_file(db, directory) @@ -61,6 +61,7 @@ LOGGER.error(f"Error while executing backup save command: {e}") status = 1 finally: - DB_LOCK_FILE.unlink(missing_ok=True) + if lock_acquired: + DB_LOCK_FILE.unlink(missing_ok=True) sys_exit(status) diff --git a/src/common/core/backup/jobs/backup-data.py b/src/common/core/backup/jobs/backup-data.py index 82acde31de..d228a5cd08 100644 --- a/src/common/core/backup/jobs/backup-data.py +++ b/src/common/core/backup/jobs/backup-data.py @@ -19,6 +19,7 @@ LOGGER = getLogger("BACKUP") status = 0 +lock_acquired = False # Track whether this process holds the DB lock try: backup_dir = Path(getenv("BACKUP_DIRECTORY", "/var/lib/bunkerweb/backups")) @@ -84,26 +85,20 @@ LOGGER.info("First start of the scheduler, skipping backup ...") sys_exit(0) - wal = is_sqlite_wal(db) - if wal: - # SQLite WAL mode: readers and writers do not block each other, so - # the dump can run without holding the BunkerWeb lock at all. - LOGGER.info("SQLite WAL mode detected — running non-blocking dump (no database lock required).") - LOGGER.debug("Starting database dump ...") - db, _ = backup_database(current_time, db, backup_dir) - else: - # Acquire the lock only when a backup will actually run. - # Placing it here (rather than at script start) means that - # rotation-only runs and already_done early exits never touch - # the lock file. Combined with the post_dump_hook that releases - # the lock after the SQL dump (before compression), the total - # lock hold time is reduced to just the dump phase (~2 s for a - # typical SQLite database, down from ~18 s with the old approach). - LOGGER.debug("Acquiring database lock ...") - acquire_db_lock() - LOGGER.debug("Database lock acquired.") - LOGGER.debug("Starting database dump ...") - db, _ = backup_database(current_time, db, backup_dir, post_dump_hook=DB_LOCK_FILE.unlink) + if is_sqlite_wal(db): + LOGGER.info("SQLite WAL mode detected — SQLite readers/writers won't block, but the BunkerWeb lock is still acquired to prevent concurrent backup/restore operations.") + + # Acquire the lock only when a backup will actually run. + # Placing it here (rather than at script start) means that + # rotation-only runs and already_done early exits never touch + # the lock file. The post_dump_hook releases the lock after the + # SQL dump but before compression, keeping the hold time minimal. + LOGGER.debug("Acquiring database lock ...") + acquire_db_lock() + lock_acquired = True + LOGGER.debug("Database lock acquired.") + LOGGER.debug("Starting database dump ...") + db, _ = backup_database(current_time, db, backup_dir, post_dump_hook=lambda: DB_LOCK_FILE.unlink(missing_ok=True)) backed_up = True LOGGER.debug("Database dump and compression complete.") @@ -144,7 +139,8 @@ LOGGER.error(f"Exception while running backup-data.py :\n{e}") finally: - # Always release DB lock - DB_LOCK_FILE.unlink(missing_ok=True) + # Only release the lock if this process acquired it (WAL mode skips the lock entirely). + if lock_acquired: + DB_LOCK_FILE.unlink(missing_ok=True) sys_exit(status) diff --git a/tests/core/backup/main.py b/tests/core/backup/main.py index f9652711fd..51cbcb55a6 100644 --- a/tests/core/backup/main.py +++ b/tests/core/backup/main.py @@ -1,5 +1,5 @@ from datetime import datetime -from os import getenv, sep +from os import environ, getenv, sep from pathlib import Path from subprocess import PIPE, Popen from time import sleep @@ -126,6 +126,90 @@ def exec_command(command: Union[str, List[str]]) -> Tuple[int, str, str]: print(f"❌ Backup files count is not as expected, the rotation failed:\nstdout={result[1]}\nstderr={result[2]}", flush=True) exit(1) + print("ℹ️ Testing checksum verification ...", flush=True) + + # Create a fresh backup dedicated to checksum tests + result = exec_command(["bwcli", "plugin", "backup", "save"]) + if result[0] != 0: + print(f"❌ Backup for checksum test failed:\nstdout={result[1]}\nstderr={result[2]}", flush=True) + exit(1) + + # Find the most recently created backup file + result = exec_command(["ls", "-1t", backup_dir]) + if result[0] != 0: + print(f"❌ Backup directory listing failed:\nstdout={result[1]}\nstderr={result[2]}", flush=True) + exit(1) + + checksum_test_files = [f for f in result[1].strip().split("\n") if f.startswith("backup-") and f.endswith(".zip")] + if not checksum_test_files: + print("❌ No backup files found for checksum test", flush=True) + exit(1) + + latest_backup = f"{backup_dir}/{checksum_test_files[0]}" + + # Corrupt the stored .sha256 (replace hash with all-zeros) while keeping the SQL intact. + # This ensures 'check' and 'restore' fail on mismatch, but 'restore' with the ignore flag + # can actually complete successfully (valid SQL, just a bad hash record). + corrupt_script = ( + "import zipfile; " + f"f='{latest_backup}'; " + "zin=zipfile.ZipFile(f,'r'); " + "names=zin.namelist(); " + "contents={n: zin.read(n) for n in names}; " + "zin.close(); " + "sha=[n for n in names if n.endswith('.sha256')][0]; " + "contents[sha]=b'0000000000000000000000000000000000000000000000000000000000000000 ' + sha[:-7].encode() + b'\\n'; " + "zout=zipfile.ZipFile(f,'w',compression=zipfile.ZIP_DEFLATED); " + "[zout.writestr(n,d) for n,d in contents.items()]; " + "zout.close()" + ) + result = exec_command(["python3", "-c", corrupt_script]) + if result[0] != 0: + print(f"❌ Failed to corrupt backup file:\nstdout={result[1]}\nstderr={result[2]}", flush=True) + exit(1) + + # Test 1: 'bwcli plugin backup check' must exit non-zero when a checksum is bad + print("ℹ️ Verifying that 'bwcli plugin backup check' detects checksum mismatch ...", flush=True) + result = exec_command(["bwcli", "plugin", "backup", "check"]) + if result[0] == 0: + print(f"❌ 'bwcli plugin backup check' should have failed but returned 0:\nstdout={result[1]}\nstderr={result[2]}", flush=True) + exit(1) + print("✅ 'bwcli plugin backup check' correctly detected checksum mismatch", flush=True) + + # Test 2: restore must abort when checksum does not match + print("ℹ️ Verifying that restore aborts on checksum mismatch ...", flush=True) + result = exec_command(["bwcli", "plugin", "backup", "restore", latest_backup]) + if result[0] == 0: + print(f"❌ 'bwcli plugin backup restore' should have failed but returned 0:\nstdout={result[1]}\nstderr={result[2]}", flush=True) + exit(1) + print("✅ 'bwcli plugin backup restore' correctly aborted on checksum mismatch", flush=True) + + # Test 3: restore must succeed when BACKUP_IGNORE_CHECKSUM_ERROR_ON_DB_RESTORE=yes + print("ℹ️ Verifying that restore proceeds with BACKUP_IGNORE_CHECKSUM_ERROR_ON_DB_RESTORE=yes ...", flush=True) + ignore_env = {"BACKUP_IGNORE_CHECKSUM_ERROR_ON_DB_RESTORE": "yes"} + if scheduler_instance: + result_obj = scheduler_instance.exec_run( + ["bwcli", "plugin", "backup", "restore", latest_backup], + environment=ignore_env, + ) + rc = result_obj.exit_code + out = result_obj.output.decode() if result_obj.output else "" + else: + proc = Popen( + ["bwcli", "plugin", "backup", "restore", latest_backup], + stdout=PIPE, + stderr=PIPE, + universal_newlines=True, + text=True, + env={**environ, **ignore_env}, + ) + out, _ = proc.communicate() + rc = proc.returncode + if rc != 0: + print(f"❌ Restore with BACKUP_IGNORE_CHECKSUM_ERROR_ON_DB_RESTORE=yes failed (exit {rc}):\n{out}", flush=True) + exit(1) + print("✅ Restore proceeded correctly with BACKUP_IGNORE_CHECKSUM_ERROR_ON_DB_RESTORE=yes", flush=True) + print("✅ Backup tests completed successfully", flush=True) except SystemExit as se: exit(se.code) From 02d00652368f36c20c0e12d2e567a02e2d38cf7a Mon Sep 17 00:00:00 2001 From: sysangels | Michal Koeckeis-Fresel Date: Fri, 30 Oct 2026 22:08:16 +0100 Subject: [PATCH 3/3] fix(backup): add missing Hanoi and checksum tests, handle the repeated DST hour, sync docs --- docs/de/features.md | 50 +++-- docs/es/features.md | 72 ++++--- docs/features.md | 42 +++- docs/fr/features.md | 34 +++- docs/zh/features.md | 60 +++--- src/common/core/backup/backup.py | 22 +- tests/unit/backup/test_backup_checksum.py | 188 +++++++++++++++++ tests/unit/backup/test_db_lock_processes.py | 1 + tests/unit/backup/test_hanoi_rotation.py | 213 ++++++++++++++++++++ 9 files changed, 587 insertions(+), 95 deletions(-) create mode 100644 tests/unit/backup/test_backup_checksum.py create mode 100644 tests/unit/backup/test_hanoi_rotation.py diff --git a/docs/de/features.md b/docs/de/features.md index 9b1389ff40..0dfc8a7e41 100644 --- a/docs/de/features.md +++ b/docs/de/features.md @@ -820,30 +820,32 @@ Das Backup-Plugin bietet eine automatisierte Backup-Lösung zum Schutz Ihrer Bun **So funktioniert es:** -1. Ihre Datenbank wird automatisch gemäß dem von Ihnen festgelegten Zeitplan (täglich, wöchentlich oder monatlich) gesichert. +1. Ihre Datenbank wird automatisch gemäß dem von Ihnen festgelegten Zeitplan (`daily`, `weekly`, `monthly` oder `hanoi`) gesichert. 2. Backups werden in einem angegebenen Verzeichnis auf Ihrem System gespeichert. -3. Alte Backups werden automatisch basierend auf Ihren Aufbewahrungseinstellungen rotiert. -4. Sie können jederzeit manuell Backups erstellen, vorhandene Backups auflisten oder eine Wiederherstellung aus einem Backup durchführen. -5. Vor jeder Wiederherstellung wird der aktuelle Zustand als Sicherheitsmaßnahme automatisch gesichert. +3. Vor jedem Backup wird der verfügbare Speicherplatz geprüft. +4. Jede Backup-ZIP enthält den SQL-Dump und eine SHA-256-Prüfsummendatei (`.sha256`) zur Integritätsprüfung. +5. Alte Backups werden automatisch rotiert. Der Modus `hanoi` verwendet die Türme-von-Hanoi-Rotation (24–34 Dateien, 64–85 Tage Abdeckung). +6. Beim Wiederherstellen wird die Prüfsumme verifiziert, bevor die aktive Datenbank verändert wird. +7. Sie können jederzeit manuell Backups erstellen, auflisten, prüfen oder wiederherstellen. +8. Vor jeder Wiederherstellung wird der aktuelle Zustand als Sicherheitsmaßnahme automatisch gesichert. ### Wie man es benutzt -Führen Sie die folgenden Schritte aus, um die Backup-Funktion zu konfigurieren und zu verwenden: - 1. **Aktivieren Sie die Funktion:** Die Backup-Funktion ist standardmäßig aktiviert. Bei Bedarf können Sie dies mit der Einstellung `USE_BACKUP` steuern. 2. **Backup-Zeitplan konfigurieren:** Wählen Sie mit dem Parameter `BACKUP_SCHEDULE`, wie oft Backups durchgeführt werden sollen. -3. **Aufbewahrungsrichtlinie festlegen:** Geben Sie mit der Einstellung `BACKUP_ROTATION` an, wie viele Backups aufbewahrt werden sollen. +3. **Aufbewahrungsrichtlinie festlegen:** Geben Sie mit der Einstellung `BACKUP_ROTATION` an, wie viele Backups aufbewahrt werden sollen (wird bei `BACKUP_SCHEDULE=hanoi` ignoriert). 4. **Speicherort festlegen:** Wählen Sie mit der Einstellung `BACKUP_DIRECTORY`, wo die Backups gespeichert werden sollen. 5. **CLI-Befehle verwenden:** Verwalten Sie Backups bei Bedarf manuell mit den `bwcli plugin backup`-Befehlen. ### Konfigurationseinstellungen -| Einstellung | Standard | Kontext | Mehrfach | Beschreibung | -| ------------------ | ---------------------------- | ------- | -------- | -------------------------------------------------------------------------------------------------------------------- | -| `USE_BACKUP` | `yes` | global | nein | **Backup aktivieren:** Auf `yes` setzen, um automatische Backups zu aktivieren. | -| `BACKUP_SCHEDULE` | `daily` | global | nein | **Backup-Frequenz:** Wie oft Backups durchgeführt werden sollen. Optionen: `daily`, `weekly` oder `monthly`. | -| `BACKUP_ROTATION` | `7` | global | nein | **Backup-Aufbewahrung:** Die Anzahl der aufzubewahrenden Backup-Dateien. Ältere Backups werden automatisch gelöscht. | -| `BACKUP_DIRECTORY` | `/var/lib/bunkerweb/backups` | global | nein | **Backup-Speicherort:** Das Verzeichnis, in dem die Backup-Dateien gespeichert werden. | +| Einstellung | Standard | Kontext | Mehrfach | Beschreibung | +| ------------------ | ---------------------------- | ------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `USE_BACKUP` | `yes` | global | nein | **Backup aktivieren:** Auf `yes` setzen, um automatische Backups zu aktivieren. | +| `BACKUP_SCHEDULE` | `daily` | global | nein | **Backup-Frequenz:** `daily`, `weekly`, `monthly` oder `hanoi` (stündlich mit Türme-von-Hanoi-Rotation, 64–85 Tage Abdeckung). | +| `BACKUP_ROTATION` | `7` | global | nein | **Backup-Aufbewahrung:** Anzahl der aufzubewahrenden Backup-Dateien. Wird bei `hanoi` ignoriert (Rotation wird automatisch verwaltet). | +| `BACKUP_DIRECTORY` | `/var/lib/bunkerweb/backups` | global | nein | **Backup-Speicherort:** Das Verzeichnis, in dem die Backup-Dateien gespeichert werden. | +| `BACKUP_IGNORE_CHECKSUM_ERROR_ON_DB_RESTORE` | `no` | global | nein | **Prüfsummenfehler ignorieren:** Bei `yes` wird ein fehlgeschlagener SHA-256-Check die Wiederherstellung nicht abbrechen. Nur als letztes Mittel verwenden. | ### Befehlszeilenschnittstelle @@ -853,6 +855,9 @@ Das Backup-Plugin bietet mehrere CLI-Befehle zur Verwaltung Ihrer Backups: # Alle verfügbaren Backups auflisten bwcli plugin backup list +# SHA-256-Prüfsummen aller Backups prüfen +bwcli plugin backup check + # Ein manuelles Backup erstellen bwcli plugin backup save @@ -867,7 +872,10 @@ bwcli plugin backup restore /pfad/zum/backup/backup-sqlite-2023-08-15_12-34-56.z ``` !!! tip "Sicherheit geht vor" - Vor jeder Wiederherstellung erstellt das Backup-Plugin automatisch ein Backup des aktuellen Zustands Ihrer Datenbank an einem temporären Ort. Dies bietet eine zusätzliche Absicherung für den Fall, dass Sie die Wiederherstellung rückgängig machen müssen. + Vor jeder Wiederherstellung erstellt das Backup-Plugin automatisch ein Backup des aktuellen Zustands Ihrer Datenbank an einem temporären Ort. + +!!! info "Integritätsprüfung" + Jede ZIP enthält eine SHA-256-Prüfsummendatei. Die Prüfsumme wird automatisch vor jeder Wiederherstellung verifiziert. Verwenden Sie `bwcli plugin backup check`, um alle Backups jederzeit zu prüfen. !!! warning "Datenbankkompatibilität" Das Backup-Plugin unterstützt SQLite, MySQL/MariaDB und PostgreSQL-Datenbanken. Oracle-Datenbanken werden derzeit für Backup- und Wiederherstellungsvorgänge nicht unterstützt. Eine Wiederherstellung lehnt ein Backup aus einer anderen Datenbank-Engine ab (MariaDB und MySQL gelten als verschiedene Engines), bevor etwas geändert wird. @@ -885,9 +893,17 @@ bwcli plugin backup restore /pfad/zum/backup/backup-sqlite-2023-08-15_12-34-56.z BACKUP_DIRECTORY: "/var/lib/bunkerweb/backups" ``` -=== "Wöchentliche Backups mit erweiterter Aufbewahrung" +=== "Türme von Hanoi (64–85 Tage)" + + Stündliche Ausführung mit Türme-von-Hanoi-Rotation. Hält 24–34 Dateien mit 64–85 Tagen Abdeckung. `BACKUP_ROTATION` wird ignoriert: + + ```yaml + USE_BACKUP: "yes" + BACKUP_SCHEDULE: "hanoi" + BACKUP_DIRECTORY: "/var/lib/bunkerweb/backups" + ``` - Konfiguration für seltenere Backups mit längerer Aufbewahrung: +=== "Wöchentliche Backups mit erweiterter Aufbewahrung" ```yaml USE_BACKUP: "yes" @@ -898,8 +914,6 @@ bwcli plugin backup restore /pfad/zum/backup/backup-sqlite-2023-08-15_12-34-56.z === "Monatliche Backups an einem benutzerdefinierten Speicherort" - Konfiguration für monatliche Backups, die an einem benutzerdefinierten Ort gespeichert werden: - ```yaml USE_BACKUP: "yes" BACKUP_SCHEDULE: "monthly" diff --git a/docs/es/features.md b/docs/es/features.md index a78b0e94a4..7133b82306 100644 --- a/docs/es/features.md +++ b/docs/es/features.md @@ -829,63 +829,69 @@ El complemento de copia de seguridad proporciona una solución de respaldo autom **Cómo funciona:** -1. Su base de datos se respalda automáticamente según el cronograma que establezca (diario, semanal o mensual). +1. Su base de datos se respalda automáticamente según el cronograma que establezca (`daily`, `weekly`, `monthly` o `hanoi`). 2. Las copias de seguridad se almacenan en un directorio específico de su sistema. -3. Las copias de seguridad antiguas se rotan automáticamente según su configuración de retención. -4. Puede crear copias de seguridad manualmente, enumerar las copias de seguridad existentes o restaurar desde una copia de seguridad en cualquier momento. -5. Antes de cualquier operación de restauración, el estado actual se respalda automáticamente como medida de seguridad. +3. Antes de cada copia de seguridad se verifica el espacio libre en disco disponible. +4. Cada ZIP contiene el volcado SQL y un archivo de suma de verificación SHA-256 (`.sha256`) para verificar la integridad. +5. Las copias de seguridad antiguas se rotan automáticamente. El modo `hanoi` aplica la rotación Torres de Hanói (24–34 archivos, 64–85 días de cobertura). +6. Al restaurar, la suma de verificación se comprueba antes de modificar la base de datos activa. +7. Puede crear, listar, verificar y restaurar copias de seguridad manualmente en cualquier momento. +8. Antes de cualquier operación de restauración, el estado actual se respalda automáticamente como medida de seguridad. ### Cómo usar -Siga estos pasos para configurar y utilizar la función de copia de seguridad: - 1. **Habilite la función:** La función de copia de seguridad está habilitada por defecto. Si es necesario, puede controlarla con el ajuste `USE_BACKUP`. -2. **Configure el cronograma de copia de seguridad:** Elija la frecuencia con la que deben realizarse las copias de seguridad estableciendo el parámetro `BACKUP_SCHEDULE`. -3. **Establezca la política de retención:** Especifique cuántas copias de seguridad conservar utilizando el ajuste `BACKUP_ROTATION`. -4. **Defina la ubicación de almacenamiento:** Elija dónde se almacenarán las copias de seguridad utilizando el ajuste `BACKUP_DIRECTORY`. -5. **Use los comandos de la CLI:** Gestione las copias de seguridad manualmente con los comandos `bwcli plugin backup` cuando sea necesario. +2. **Configure el cronograma:** Elija la frecuencia con la que deben realizarse las copias de seguridad estableciendo el parámetro `BACKUP_SCHEDULE`. +3. **Establezca la política de retención:** Especifique cuántas copias conservar con `BACKUP_ROTATION` (se ignora si `BACKUP_SCHEDULE=hanoi`). +4. **Defina la ubicación:** Elija dónde se almacenarán las copias con `BACKUP_DIRECTORY`. +5. **Use los comandos de la CLI:** Gestione las copias manualmente con los comandos `bwcli plugin backup`. ### Ajustes de configuración -| Ajuste | Valor por defecto | Contexto | Múltiple | Descripción | -| ------------------ | ---------------------------- | -------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `USE_BACKUP` | `yes` | global | no | **Habilitar copia de seguridad:** Establezca en `yes` para habilitar las copias de seguridad automáticas. | -| `BACKUP_SCHEDULE` | `daily` | global | no | **Frecuencia de la copia de seguridad:** Con qué frecuencia se realizan las copias de seguridad. Opciones: `daily`, `weekly` o `monthly`. | -| `BACKUP_ROTATION` | `7` | global | no | **Retención de copias de seguridad:** El número de archivos de copia de seguridad que se deben conservar. Las copias de seguridad más antiguas que este número se eliminarán automáticamente. | -| `BACKUP_DIRECTORY` | `/var/lib/bunkerweb/backups` | global | no | **Ubicación de la copia de seguridad:** El directorio donde se almacenarán los archivos de copia de seguridad. | +| Ajuste | Valor por defecto | Contexto | Múltiple | Descripción | +| ------------------ | ---------------------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `USE_BACKUP` | `yes` | global | no | **Habilitar copia de seguridad:** Establezca en `yes` para habilitar las copias automáticas. | +| `BACKUP_SCHEDULE` | `daily` | global | no | **Frecuencia:** `daily`, `weekly`, `monthly` o `hanoi` (horaria con rotación Torres de Hanói, 64–85 días de cobertura). | +| `BACKUP_ROTATION` | `7` | global | no | **Retención:** Número de archivos a conservar. Se ignora con `hanoi` (la rotación se gestiona automáticamente). | +| `BACKUP_DIRECTORY` | `/var/lib/bunkerweb/backups` | global | no | **Ubicación:** El directorio donde se almacenarán los archivos de copia de seguridad. | +| `BACKUP_IGNORE_CHECKSUM_ERROR_ON_DB_RESTORE` | `no` | global | no | **Ignorar error de suma de verificación:** Si `yes`, un error SHA-256 no cancelará la restauración. Úselo solo como último recurso para backups parcialmente corruptos. | ### Interfaz de línea de comandos -El complemento de copia de seguridad proporciona varios comandos de la CLI para gestionar sus copias de seguridad: - ```bash # Listar todas las copias de seguridad disponibles bwcli plugin backup list +# Verificar las sumas SHA-256 de todas las copias +bwcli plugin backup check + # Crear una copia de seguridad manual bwcli plugin backup save -# Crear una copia de seguridad en una ubicación personalizada +# Crear una copia en una ubicación personalizada bwcli plugin backup save --directory /ruta/a/ubicacion/personalizada -# Restaurar desde la copia de seguridad más reciente +# Restaurar desde la copia más reciente bwcli plugin backup restore -# Restaurar desde un archivo de copia de seguridad específico -bwcli plugin backup restore /ruta/a/copia/de/seguridad/backup-sqlite-2023-08-15_12-34-56.zip +# Restaurar desde un archivo específico +bwcli plugin backup restore /ruta/a/copia/backup-sqlite-2023-08-15_12-34-56.zip ``` !!! tip "La seguridad es lo primero" - Antes de cualquier operación de restauración, el complemento de copia de seguridad crea automáticamente una copia de seguridad del estado actual de su base de datos en una ubicación temporal. Esto proporciona una protección adicional en caso de que necesite revertir la operación de restauración. + Antes de cualquier restauración, el complemento crea automáticamente una copia del estado actual en una ubicación temporal. + +!!! info "Verificación de integridad" + Cada ZIP contiene un archivo SHA-256. La suma se verifica automáticamente antes de cualquier restauración. Use `bwcli plugin backup check` para comprobar todas las copias en cualquier momento. !!! warning "Compatibilidad de la base de datos" El complemento de copia de seguridad es compatible con las bases de datos SQLite, MySQL/MariaDB y PostgreSQL. Las bases de datos de Oracle no son compatibles actualmente para las operaciones de copia de seguridad y restauración. La restauración rechaza una copia de seguridad procedente de otro motor de base de datos (MariaDB y MySQL cuentan como motores distintos) antes de modificar nada. ### Configuraciones de ejemplo -=== "Copias de seguridad diarias con retención de 7 días" +=== "Copias diarias con retención de 7 días" - Configuración por defecto que crea copias de seguridad diarias y conserva los 7 archivos más recientes: + Configuración por defecto: ```yaml USE_BACKUP: "yes" @@ -894,9 +900,17 @@ bwcli plugin backup restore /ruta/a/copia/de/seguridad/backup-sqlite-2023-08-15_ BACKUP_DIRECTORY: "/var/lib/bunkerweb/backups" ``` -=== "Copias de seguridad semanales con retención extendida" +=== "Torres de Hanói (64–85 días)" - Configuración para copias de seguridad menos frecuentes con una retención más prolongada: + Ejecución horaria con rotación Torres de Hanói. Conserva 24–34 archivos con 64–85 días de cobertura. `BACKUP_ROTATION` se ignora: + + ```yaml + USE_BACKUP: "yes" + BACKUP_SCHEDULE: "hanoi" + BACKUP_DIRECTORY: "/var/lib/bunkerweb/backups" + ``` + +=== "Copias semanales con retención extendida" ```yaml USE_BACKUP: "yes" @@ -905,9 +919,7 @@ bwcli plugin backup restore /ruta/a/copia/de/seguridad/backup-sqlite-2023-08-15_ BACKUP_DIRECTORY: "/var/lib/bunkerweb/backups" ``` -=== "Copias de seguridad mensuales en una ubicación personalizada" - - Configuración para copias de seguridad mensuales almacenadas en una ubicación personalizada: +=== "Copias mensuales en ubicación personalizada" ```yaml USE_BACKUP: "yes" diff --git a/docs/features.md b/docs/features.md index 9070434cc0..4c70a31f63 100644 --- a/docs/features.md +++ b/docs/features.md @@ -833,11 +833,14 @@ The Backup plugin provides an automated backup solution to protect your BunkerWe **How it works:** -1. Your database is automatically backed up according to the schedule you set (daily, weekly, or monthly). +1. Your database is automatically backed up according to the schedule you set (`daily`, `weekly`, `monthly`, or `hanoi`). 2. Backups are stored in a specified directory on your system. -3. Old backups are automatically rotated based on your retention settings. -4. You can manually create backups, list existing backups, or restore from a backup at any time. -5. Before any restore operation, the current state is automatically backed up as a safety measure. +3. Before each backup a disk-space pre-flight check is performed to ensure sufficient free space. +4. Each backup ZIP contains the SQL dump and a SHA-256 checksum file (`.sha256`) for integrity verification. +5. Old backups are automatically rotated based on your retention settings. The `hanoi` schedule uses Towers of Hanoi rotation (24–34 files, 64–85 days of coverage) instead of a simple count. +6. When restoring, the checksum is verified before the live database is touched. +7. You can manually create, list, verify, or restore backups at any time. +8. Before any restore operation, the current state is automatically backed up as a safety measure. ### How to Use @@ -845,18 +848,19 @@ Follow these steps to configure and use the Backup feature: 1. **Enable the feature:** The backup feature is enabled by default. If needed, you can control this with the `USE_BACKUP` setting. 2. **Configure backup schedule:** Choose how often backups should occur by setting the `BACKUP_SCHEDULE` parameter. -3. **Set retention policy:** Specify how many backups to keep using the `BACKUP_ROTATION` setting. +3. **Set retention policy:** Specify how many backups to keep using the `BACKUP_ROTATION` setting (ignored when `BACKUP_SCHEDULE=hanoi`). 4. **Define storage location:** Choose where backups will be stored using the `BACKUP_DIRECTORY` setting. 5. **Use CLI commands:** Manage backups manually with the `bwcli plugin backup` commands when needed. ### Configuration Settings -| Setting | Default | Context | Multiple | Description | -| ------------------ | ---------------------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------- | -| `USE_BACKUP` | `yes` | global | no | **Enable Backup:** Set to `yes` to enable automatic backups. | -| `BACKUP_SCHEDULE` | `daily` | global | no | **Backup Frequency:** How often to perform backups. Options: `daily`, `weekly`, or `monthly`. | -| `BACKUP_ROTATION` | `7` | global | no | **Backup Retention:** The number of backup files to keep. Older backups beyond this number will be automatically deleted. | -| `BACKUP_DIRECTORY` | `/var/lib/bunkerweb/backups` | global | no | **Backup Location:** The directory where backup files will be stored. | +| Setting | Default | Context | Multiple | Description | +| ------------------ | ---------------------------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `USE_BACKUP` | `yes` | global | no | **Enable Backup:** Set to `yes` to enable automatic backups. | +| `BACKUP_SCHEDULE` | `daily` | global | no | **Backup Frequency:** How often to perform backups. Options: `daily`, `weekly`, `monthly`, or `hanoi` (hourly with Towers of Hanoi rotation, 64–85 days coverage). | +| `BACKUP_ROTATION` | `7` | global | no | **Backup Retention:** Number of backup files to keep. Ignored when `BACKUP_SCHEDULE=hanoi` (rotation is managed automatically by the Hanoi algorithm). | +| `BACKUP_DIRECTORY` | `/var/lib/bunkerweb/backups` | global | no | **Backup Location:** The directory where backup files will be stored. | +| `BACKUP_IGNORE_CHECKSUM_ERROR_ON_DB_RESTORE` | `no` | global | no | **Ignore checksum error on restore:** If `yes`, a failed SHA-256 checksum will not abort the restore. Use only as a last resort for partially corrupted backups. | ### Command Line Interface @@ -866,6 +870,9 @@ The Backup plugin provides several CLI commands to manage your backups: # List all available backups bwcli plugin backup list +# Verify SHA-256 checksums of all backups +bwcli plugin backup check + # Create a manual backup bwcli plugin backup save @@ -882,6 +889,9 @@ bwcli plugin backup restore /path/to/backup/backup-sqlite-2023-08-15_12-34-56.zi !!! tip "Safety First" Before any restore operation, the Backup plugin automatically creates a backup of your current database state in a temporary location. This provides an extra safeguard in case you need to revert the restore operation. +!!! info "Integrity Verification" + Every backup ZIP contains a SHA-256 checksum file alongside the SQL dump. The checksum is automatically verified before any restore. Use `bwcli plugin backup check` to verify all backups at any time. + !!! warning "Database Compatibility" The Backup plugin supports SQLite, MySQL/MariaDB, and PostgreSQL databases. Oracle databases are not currently supported for backup and restore operations. A restore refuses a backup taken from a different database engine (MariaDB and MySQL count as different engines) before changing anything. @@ -898,6 +908,16 @@ bwcli plugin backup restore /path/to/backup/backup-sqlite-2023-08-15_12-34-56.zi BACKUP_DIRECTORY: "/var/lib/bunkerweb/backups" ``` +=== "Towers of Hanoi (64–85 days coverage)" + + Runs hourly and applies Towers of Hanoi rotation. Keeps 24–34 files covering 64–85 days. `BACKUP_ROTATION` is ignored: + + ```yaml + USE_BACKUP: "yes" + BACKUP_SCHEDULE: "hanoi" + BACKUP_DIRECTORY: "/var/lib/bunkerweb/backups" + ``` + === "Weekly Backups with Extended Retention" Configuration for less frequent backups with longer retention: diff --git a/docs/fr/features.md b/docs/fr/features.md index 9a046eefba..35ed1c5963 100644 --- a/docs/fr/features.md +++ b/docs/fr/features.md @@ -811,11 +811,14 @@ Le plugin Backup fournit une solution de sauvegarde automatisée pour protéger **Comment ça marche :** -1. Votre base de données est sauvegardée automatiquement selon la planification définie (quotidienne, hebdomadaire ou mensuelle). +1. Votre base de données est sauvegardée automatiquement selon la planification définie (`daily`, `weekly`, `monthly` ou `hanoi`). 2. Les sauvegardes sont stockées dans un répertoire précis de votre système. -3. Les anciennes sauvegardes sont automatiquement supprimées selon vos paramètres de rétention. -4. Vous pouvez créer des sauvegardes, lister les sauvegardes existantes ou restaurer une sauvegarde manuellement à tout moment. -5. Avant toute opération de restauration, l'état actuel est automatiquement sauvegardé par sécurité. +3. Avant chaque sauvegarde, l'espace disque disponible est vérifié. +4. Chaque ZIP contient le dump SQL et un fichier de somme de contrôle SHA-256 (`.sha256`) pour vérifier l'intégrité. +5. Les anciennes sauvegardes sont automatiquement supprimées selon vos paramètres de rétention. Le mode `hanoi` applique la rotation Tours de Hanoï (24–34 fichiers, 64–85 jours de couverture). +6. Lors d'une restauration, la somme de contrôle est vérifiée avant toute modification de la base active. +7. Vous pouvez créer des sauvegardes, lister, vérifier ou restaurer les sauvegardes manuellement à tout moment. +8. Avant toute opération de restauration, l'état actuel est automatiquement sauvegardé par sécurité. ### Comment l'utiliser @@ -823,7 +826,7 @@ Suivez ces étapes pour configurer et utiliser la fonctionnalité Backup : 1. **Activer la fonctionnalité :** La sauvegarde est activée par défaut. Si nécessaire, vous pouvez la contrôler avec le paramètre `USE_BACKUP`. 2. **Configurer la planification :** Choisissez la fréquence des sauvegardes avec le paramètre `BACKUP_SCHEDULE`. -3. **Définir la politique de rétention :** Indiquez le nombre de sauvegardes à conserver avec le paramètre `BACKUP_ROTATION`. +3. **Définir la politique de rétention :** Indiquez le nombre de sauvegardes à conserver avec le paramètre `BACKUP_ROTATION` (ignoré si `BACKUP_SCHEDULE=hanoi`). 4. **Définir l'emplacement de stockage :** Choisissez où les sauvegardes seront stockées avec le paramètre `BACKUP_DIRECTORY`. 5. **Utiliser les commandes CLI :** Gérez les sauvegardes manuellement avec les commandes `bwcli plugin backup` lorsque c'est nécessaire. @@ -832,9 +835,10 @@ Suivez ces étapes pour configurer et utiliser la fonctionnalité Backup : | Paramètre | Défaut | Contexte | Multiple | Description | | ------------------ | ---------------------------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `USE_BACKUP` | `yes` | global | non | **Activer Backup :** Mettre à `yes` pour activer les sauvegardes automatiques. | -| `BACKUP_SCHEDULE` | `daily` | global | non | **Fréquence de sauvegarde :** Fréquence d'exécution des sauvegardes. Options : `daily`, `weekly` ou `monthly`. | -| `BACKUP_ROTATION` | `7` | global | non | **Rétention des sauvegardes :** Nombre de fichiers de sauvegarde à conserver. Les sauvegardes plus anciennes seront supprimées. | +| `BACKUP_SCHEDULE` | `daily` | global | non | **Fréquence de sauvegarde :** Fréquence d'exécution des sauvegardes. Options : `daily`, `weekly`, `monthly` ou `hanoi` (horaire avec rotation Tours de Hanoï, 64–85 jours de couverture). | +| `BACKUP_ROTATION` | `7` | global | non | **Rétention des sauvegardes :** Nombre de fichiers de sauvegarde à conserver. Les sauvegardes plus anciennes seront supprimées. Ignoré pour `hanoi` (la rotation est gérée automatiquement par l'algorithme). | | `BACKUP_DIRECTORY` | `/var/lib/bunkerweb/backups` | global | non | **Emplacement des sauvegardes :** Répertoire dans lequel les fichiers de sauvegarde seront stockés. | +| `BACKUP_IGNORE_CHECKSUM_ERROR_ON_DB_RESTORE` | `no` | global | non | Si `yes`, un échec de vérification SHA-256 n'interrompt pas la restauration. À utiliser en dernier recours pour les sauvegardes partiellement corrompues. | ### Interface en ligne de commande @@ -844,6 +848,9 @@ Le plugin Backup fournit plusieurs commandes CLI pour gérer vos sauvegardes : # Lister toutes les sauvegardes disponibles bwcli plugin backup list +# Vérifier les sommes de contrôle SHA-256 +bwcli plugin backup check + # Créer une sauvegarde manuelle bwcli plugin backup save @@ -860,6 +867,9 @@ bwcli plugin backup restore /path/to/backup/backup-sqlite-2023-08-15_12-34-56.zi !!! tip "Priorité à la sécurité" Avant toute opération de restauration, le plugin Backup crée automatiquement une sauvegarde de l'état actuel de votre base de données dans un emplacement temporaire. Cela fournit une protection supplémentaire si vous devez annuler la restauration. +!!! info "Vérification d'intégrité" + Chaque ZIP contient un fichier SHA-256. La somme est vérifiée automatiquement avant toute restauration. Utilisez `bwcli plugin backup check` pour vérifier tous les backups à tout moment. + !!! warning "Compatibilité des bases de données" Le plugin Backup prend en charge les bases SQLite, MySQL/MariaDB et PostgreSQL. Les bases Oracle ne sont pas prises en charge actuellement pour les opérations de sauvegarde et de restauration. Une restauration refuse une sauvegarde issue d'un autre moteur de base de données (MariaDB et MySQL comptent comme deux moteurs différents) avant de modifier quoi que ce soit. @@ -876,6 +886,16 @@ bwcli plugin backup restore /path/to/backup/backup-sqlite-2023-08-15_12-34-56.zi BACKUP_DIRECTORY: "/var/lib/bunkerweb/backups" ``` +=== "Tours de Hanoï (64–85 jours)" + + Exécution horaire avec rotation Tours de Hanoï. Conserve 24–34 fichiers couvrant 64–85 jours. `BACKUP_ROTATION` est ignoré : + + ```yaml + USE_BACKUP: "yes" + BACKUP_SCHEDULE: "hanoi" + BACKUP_DIRECTORY: "/var/lib/bunkerweb/backups" + ``` + === "Sauvegardes hebdomadaires avec rétention étendue" Configuration pour des sauvegardes moins fréquentes avec une rétention plus longue : diff --git a/docs/zh/features.md b/docs/zh/features.md index fdc6c8a22d..6905e6f37c 100644 --- a/docs/zh/features.md +++ b/docs/zh/features.md @@ -835,63 +835,67 @@ STREAM 支持 :white_check_mark: **工作原理:** -1. 您的数据库会根据您设置的时间表(每日、每周或每月)自动备份。 +1. 您的数据库会根据您设置的时间表(`daily`、`weekly`、`monthly` 或 `hanoi`)自动备份。 2. 备份存储在您系统上的指定目录中。 -3. 旧的备份会根据您的保留设置自动轮换。 -4. 您可以随时手动创建备份、列出现有备份或从备份中恢复。 -5. 在任何恢复操作之前,当前状态都会自动备份作为安全措施。 +3. 每次备份前会检查可用磁盘空间。 +4. 每个备份 ZIP 包含 SQL 转储文件和 SHA-256 校验和文件(`.sha256`),用于完整性验证。 +5. 旧备份会自动轮换。`hanoi` 模式采用汉诺塔轮换算法(保留 24–34 个文件,覆盖 64–85 天)。 +6. 恢复时,在修改活动数据库之前会自动验证校验和。 +7. 您可以随时手动创建、列出、验证或从备份中恢复。 +8. 在任何恢复操作之前,当前状态都会自动备份作为安全措施。 ### 如何使用 -请按照以下步骤配置和使用备份功能: - 1. **启用该功能:** 备份功能默认启用。如果需要,您可以使用 `USE_BACKUP` 设置来控制此功能。 2. **配置备份计划:** 通过设置 `BACKUP_SCHEDULE` 参数选择备份的频率。 -3. **设置保留策略:** 使用 `BACKUP_ROTATION` 设置指定要保留的备份数量。 +3. **设置保留策略:** 使用 `BACKUP_ROTATION` 设置指定要保留的备份数量(`BACKUP_SCHEDULE=hanoi` 时忽略此设置)。 4. **定义存储位置:** 使用 `BACKUP_DIRECTORY` 设置选择备份的存储位置。 5. **使用 CLI 命令:** 需要时,使用 `bwcli plugin backup` 命令手动管理备份。 ### 配置设置 -| 设置 | 默认值 | 上下文 | 多个 | 描述 | -| ------------------ | ---------------------------- | ------ | ---- | --------------------------------------------------------------------- | -| `USE_BACKUP` | `yes` | 全局 | 否 | **启用备份:** 设置为 `yes` 以启用自动备份。 | -| `BACKUP_SCHEDULE` | `daily` | 全局 | 否 | **备份频率:** 执行备份的频率。选项:`daily`、`weekly` 或 `monthly`。 | -| `BACKUP_ROTATION` | `7` | 全局 | 否 | **备份保留:** 要保留的备份文件数量。超过此数量的旧备份将被自动删除。 | -| `BACKUP_DIRECTORY` | `/var/lib/bunkerweb/backups` | 全局 | 否 | **备份位置:** 备份文件将存储的目录。 | +| 设置 | 默认值 | 上下文 | 多个 | 描述 | +| ------------------ | ---------------------------- | ------ | ---- | ------------------------------------------------------------------------------------------------------------- | +| `USE_BACKUP` | `yes` | 全局 | 否 | **启用备份:** 设置为 `yes` 以启用自动备份。 | +| `BACKUP_SCHEDULE` | `daily` | 全局 | 否 | **备份频率:** `daily`、`weekly`、`monthly` 或 `hanoi`(每小时执行,采用汉诺塔轮换,覆盖 64–85 天)。 | +| `BACKUP_ROTATION` | `7` | 全局 | 否 | **备份保留:** 要保留的备份文件数量。使用 `hanoi` 时忽略(轮换由算法自动管理)。 | +| `BACKUP_DIRECTORY` | `/var/lib/bunkerweb/backups` | 全局 | 否 | **备份位置:** 备份文件将存储的目录。 | +| `BACKUP_IGNORE_CHECKSUM_ERROR_ON_DB_RESTORE` | `no` | 全局 | 否 | **忽略恢复时的校验和错误:** 设为 `yes` 时,SHA-256 校验失败不会中止恢复操作。仅在备份文件部分损坏时作为最后手段使用。 | ### 命令行界面 -备份插件提供了几个 CLI 命令来管理您的备份: - ```bash # 列出所有可用的备份 bwcli plugin backup list +# 验证所有备份的 SHA-256 校验和 +bwcli plugin backup check + # 创建一个手动备份 bwcli plugin backup save -# 在自定义位置创建一个备份 +# 在自定义位置创建备份 bwcli plugin backup save --directory /path/to/custom/location # 从最近的备份恢复 bwcli plugin backup restore -# 从特定的备份文件恢复 +# 从特定备份文件恢复 bwcli plugin backup restore /path/to/backup/backup-sqlite-2023-08-15_12-34-56.zip ``` !!! tip "安全第一" - 在任何恢复操作之前,备份插件会自动在临时位置创建您当前数据库状态的备份。如果您需要还原恢复操作,这提供了一个额外的保障。 + 在任何恢复操作之前,备份插件会自动在临时位置创建当前数据库状态的备份。 + +!!! info "完整性验证" + 每个 ZIP 包含一个 SHA-256 校验和文件。在任何恢复操作之前会自动验证校验和。使用 `bwcli plugin backup check` 随时检查所有备份。 !!! warning "数据库兼容性" 备份插件支持 SQLite、MySQL/MariaDB 和 PostgreSQL 数据库。目前不支持 Oracle 数据库的备份和恢复操作。恢复操作会在修改任何内容之前拒绝来自其他数据库引擎的备份(MariaDB 和 MySQL 视为不同的引擎)。 ### 示例配置 -=== "每日备份,保留 7 天" - - 默认配置,创建每日备份并保留最近的 7 个文件: +=== "每日备份,保留 7 天(默认)" ```yaml USE_BACKUP: "yes" @@ -900,9 +904,17 @@ bwcli plugin backup restore /path/to/backup/backup-sqlite-2023-08-15_12-34-56.zi BACKUP_DIRECTORY: "/var/lib/bunkerweb/backups" ``` -=== "每周备份,延长保留期" +=== "汉诺塔轮换(64–85 天)" + + 每小时执行,采用汉诺塔轮换算法。保留 24–34 个文件,覆盖 64–85 天。`BACKUP_ROTATION` 被忽略: - 用于频率较低但保留时间较长的备份的配置: + ```yaml + USE_BACKUP: "yes" + BACKUP_SCHEDULE: "hanoi" + BACKUP_DIRECTORY: "/var/lib/bunkerweb/backups" + ``` + +=== "每周备份,延长保留期" ```yaml USE_BACKUP: "yes" @@ -913,8 +925,6 @@ bwcli plugin backup restore /path/to/backup/backup-sqlite-2023-08-15_12-34-56.zi === "每月备份到自定义位置" - 用于每月备份并存储在自定义位置的配置: - ```yaml USE_BACKUP: "yes" BACKUP_SCHEDULE: "monthly" diff --git a/src/common/core/backup/backup.py b/src/common/core/backup/backup.py index 17937230de..20fa22dbfe 100644 --- a/src/common/core/backup/backup.py +++ b/src/common/core/backup/backup.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -from contextlib import closing +from contextlib import closing, suppress from datetime import datetime, timedelta from hashlib import sha256 import re @@ -153,6 +153,21 @@ def check_disk_space(database: str, db: Database, backup_dir: Path, db_path: Opt ] +def backup_taken_at(backup_file: Path, local: datetime) -> datetime: + """When a backup was taken, from the naive local time in its name. + + In the hour a DST change repeats, that name stands for two instants: take the one closest to the + file's modification time, which is written when the backup is taken. + """ + first, second = local.replace(fold=0).astimezone(), local.replace(fold=1).astimezone() + if first == second: + return first + with suppress(OSError): + mtime = backup_file.stat().st_mtime + return min((first, second), key=lambda instant: abs(instant.timestamp() - mtime)) + return first + + def hanoi_rotation(backup_files: List[Path], now: datetime) -> List[Path]: """Return the list of files to DELETE under Towers-of-Hanoi rotation. @@ -164,14 +179,13 @@ def hanoi_rotation(backup_files: List[Path], now: datetime) -> List[Path]: Keeps 24 to 34 files (a tier's age range can touch one bucket more than its count); the oldest is 64 to 85 days old, depending on where the buckets fall. - Backups dated in the future (clock change) are kept. + Backups dated in the future (clock change) are kept, and see backup_taken_at() for the hour a DST change repeats. """ timed: List[Tuple[datetime, Path]] = [] for f in backup_files: m = re.search(r"backup-\w+-(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})", f.name) if m: - dt = datetime.strptime(m.group(1), "%Y-%m-%d_%H-%M-%S").astimezone() - timed.append((dt, f)) + timed.append((backup_taken_at(f, datetime.strptime(m.group(1), "%Y-%m-%d_%H-%M-%S")), f)) timed.sort(reverse=True) # newest first diff --git a/tests/unit/backup/test_backup_checksum.py b/tests/unit/backup/test_backup_checksum.py new file mode 100644 index 0000000000..50a05b3e42 --- /dev/null +++ b/tests/unit/backup/test_backup_checksum.py @@ -0,0 +1,188 @@ +"""SHA-256 checksums in backup archives, `bwcli plugin backup check`, and the disk-space pre-flight. + +Every archive carries `.sql.sha256` next to the dump. A restore verifies it after the engine and +archive checks and before dropping a single table; `BACKUP_IGNORE_CHECKSUM_ERROR_ON_DB_RESTORE=yes` +lets a restore through a mismatch as a last resort. Archives from before checksums still restore. +""" + +import sys +from datetime import datetime +from hashlib import sha256 +from pathlib import Path +from runpy import run_path +from subprocess import CompletedProcess +from types import SimpleNamespace +from unittest.mock import MagicMock, patch +from zipfile import ZIP_DEFLATED, ZipFile + +import pytest + +_BACKUP = Path(__file__).resolve().parents[3] / "src" / "common" / "core" / "backup" +if str(_BACKUP) not in sys.path: + sys.path.insert(0, str(_BACKUP)) + +import backup # noqa: E402 + +URI = "mariadb+pymysql://bw:s@db:3306/bunkerweb" +DUMP = b"-- dump\nCREATE TABLE bw_plugins (id INT);\n" +OK = CompletedProcess([], 0, DUMP, b"") + + +def _db(): + return SimpleNamespace(database_uri=URI, sql_engine=MagicMock(), checked_changes=lambda **kwargs: "") + + +def _make_backup(tmp_path): + with patch.object(backup, "inspect") as inspect_mock, patch.object(backup, "run", return_value=OK): + inspect_mock.return_value.get_table_names.return_value = ["bw_plugins"] + inspect_mock.return_value.get_view_names.return_value = [] + _, archive = backup.backup_database(datetime.now().astimezone(), db=_db(), backup_dir=tmp_path) + return archive + + +def _rewrite(archive, **members): + with ZipFile(archive) as zin: + contents = {name: zin.read(name) for name in zin.namelist()} + contents.update(members) + with ZipFile(archive, "w", compression=ZIP_DEFLATED) as zout: + for name, data in contents.items(): + if data is not None: + zout.writestr(name, data) + + +def _sha_name(archive): + return archive.with_suffix(".sql").name + ".sha256" + + +def test_a_backup_stores_the_sha256_of_its_dump(tmp_path): + archive = _make_backup(tmp_path) + + with ZipFile(archive) as zipf: + sql_name = archive.with_suffix(".sql").name + assert sorted(zipf.namelist()) == sorted([sql_name, _sha_name(archive)]) + assert zipf.read(_sha_name(archive)).decode() == f"{sha256(DUMP).hexdigest()} {sql_name}\n" + assert backup.verify_backup_checksum(archive) + + +def test_a_tampered_checksum_is_reported(tmp_path): + archive = _make_backup(tmp_path) + _rewrite(archive, **{_sha_name(archive): b"0" * 64 + b" x.sql\n"}) + + assert not backup.verify_backup_checksum(archive) + + +def test_an_archive_from_before_checksums_still_verifies(tmp_path): + archive = _make_backup(tmp_path) + _rewrite(archive, **{_sha_name(archive): None}) + + assert backup.verify_backup_checksum(archive) + + +def test_an_unreadable_archive_fails_verification(tmp_path): + archive = tmp_path / "backup-mariadb-2026-01-01_00-00-00.zip" + archive.write_bytes(b"not a zip") + + assert not backup.verify_backup_checksum(archive) + + +def _restore(archive, monkeypatch, ignore): + if ignore: + monkeypatch.setenv("BACKUP_IGNORE_CHECKSUM_ERROR_ON_DB_RESTORE", "yes") + else: + monkeypatch.delenv("BACKUP_IGNORE_CHECKSUM_ERROR_ON_DB_RESTORE", raising=False) + with patch.object(backup.Base.metadata, "drop_all") as drop_all, patch.object(backup, "run", return_value=OK) as run_mock: + try: + backup.restore_database(archive, db=_db()) + except SystemExit as e: + return e.code, drop_all.called, run_mock.called + return 0, drop_all.called, run_mock.called + + +def test_a_checksum_mismatch_aborts_the_restore_before_any_table_is_dropped(tmp_path, monkeypatch): + archive = _make_backup(tmp_path) + _rewrite(archive, **{_sha_name(archive): b"0" * 64 + b" x.sql\n"}) + + status, dropped, restored = _restore(archive, monkeypatch, ignore=False) + + assert status == 1 + assert not dropped and not restored + + +def test_the_ignore_setting_lets_a_mismatched_restore_through(tmp_path, monkeypatch): + archive = _make_backup(tmp_path) + _rewrite(archive, **{_sha_name(archive): b"0" * 64 + b" x.sql\n"}) + + status, dropped, restored = _restore(archive, monkeypatch, ignore=True) + + assert status == 0 + assert dropped and restored + + +def test_a_valid_backup_restores(tmp_path, monkeypatch): + status, dropped, restored = _restore(_make_backup(tmp_path), monkeypatch, ignore=False) + + assert (status, dropped, restored) == (0, True, True) + + +def _check(tmp_path, monkeypatch): + monkeypatch.setattr(backup, "BACKUP_DIR", tmp_path) + monkeypatch.setattr(sys, "argv", ["check.py"]) + with pytest.raises(SystemExit) as exit_info: + run_path(str(_BACKUP / "bwcli" / "check.py"), run_name="__main__") + return exit_info.value.code + + +def test_bwcli_check_passes_on_good_backups_and_fails_on_a_bad_one(tmp_path, monkeypatch): + good = _make_backup(tmp_path) + assert _check(tmp_path, monkeypatch) == 0 + + bad = tmp_path / good.name.replace("backup-mariadb-", "backup-mariadb-9") + bad.write_bytes(good.read_bytes()) + _rewrite(bad, **{_sha_name(bad): b"0" * 64 + b" x.sql\n"}) + assert _check(tmp_path, monkeypatch) == 1 + + +def test_bwcli_check_with_no_backups_succeeds(tmp_path, monkeypatch): + assert _check(tmp_path, monkeypatch) == 0 + + +# --- disk space --------------------------------------------------------------------------------- + + +def _engine_reporting(size): + engine = MagicMock() + engine.connect.return_value.__enter__.return_value.execute.return_value.fetchone.return_value = (size,) + return SimpleNamespace(database_uri=URI, sql_engine=engine) + + +def test_too_little_free_space_stops_the_backup_before_the_dump(tmp_path, monkeypatch): + monkeypatch.setattr(backup, "disk_usage", lambda path: SimpleNamespace(free=1_000)) + db = _engine_reporting(1_000) # needs 2x the database size + + with patch.object(backup, "run") as run_mock, pytest.raises(SystemExit) as exit_info: + backup.backup_database(datetime.now().astimezone(), db=db, backup_dir=tmp_path) + + assert exit_info.value.code == 1 + assert not run_mock.called + assert list(tmp_path.iterdir()) == [] + + +def test_enough_free_space_passes(tmp_path, monkeypatch): + monkeypatch.setattr(backup, "disk_usage", lambda path: SimpleNamespace(free=2_000)) + + assert backup.check_disk_space("mariadb", _engine_reporting(1_000), tmp_path) + + +def test_a_failing_size_query_never_blocks_the_backup(tmp_path): + engine = MagicMock() + engine.connect.side_effect = RuntimeError("Access denied for user") + + assert backup.check_disk_space("mariadb", SimpleNamespace(sql_engine=engine), tmp_path) + + +def test_sqlite_uses_the_database_file_size(tmp_path, monkeypatch): + db_file = tmp_path / "db.sqlite3" + db_file.write_bytes(b"x" * 600) + monkeypatch.setattr(backup, "disk_usage", lambda path: SimpleNamespace(free=1_000)) + + assert not backup.check_disk_space("sqlite", None, tmp_path, db_path=db_file) diff --git a/tests/unit/backup/test_db_lock_processes.py b/tests/unit/backup/test_db_lock_processes.py index a47aebe262..c6701707b3 100644 --- a/tests/unit/backup/test_db_lock_processes.py +++ b/tests/unit/backup/test_db_lock_processes.py @@ -157,6 +157,7 @@ def __init__(self, tmp_path, busy): self.restore_database = lambda *args, **kwargs: None self.sorted_backups = lambda *args, **kwargs: [] self.rotate_backups = lambda *args, **kwargs: None + self.hanoi_rotation = lambda *args, **kwargs: [] def acquire_db_lock(self, *args, **kwargs): if self.busy: diff --git a/tests/unit/backup/test_hanoi_rotation.py b/tests/unit/backup/test_hanoi_rotation.py new file mode 100644 index 0000000000..30de77ca95 --- /dev/null +++ b/tests/unit/backup/test_hanoi_rotation.py @@ -0,0 +1,213 @@ +"""`hanoi_rotation` -- what survives months of hourly backups, and the job that drives it. + +The first version anchored its windows on *now* and kept the newest backup of each window. As the +windows slid an hour per run, the backup a window would need an hour later had already been deleted +by the window before it: after 100 days it held 16 files instead of 24, missed the 2h and 3h old +backups, and reached back 64 days. Buckets are aligned on the epoch now, each twice as wide as the +one before, so the backup a coarse bucket keeps is one a finer bucket kept. + +The job compared the last backup's end time against "one hour ago", and the next hourly run starts +an hour after the previous one started, so every other run skipped: `hanoi` backed up every 2 hours. +""" + +import logging +import os +import random +import re +import sys +from datetime import datetime, timedelta +from json import dumps +from pathlib import Path +from runpy import run_path +from time import tzset +from types import ModuleType, SimpleNamespace + +import pytest + +_BACKUP = Path(__file__).resolve().parents[3] / "src" / "common" / "core" / "backup" +if str(_BACKUP) not in sys.path: + sys.path.insert(0, str(_BACKUP)) + +import backup # noqa: E402 + +START = datetime(2026, 1, 1, 0, 0, 30).astimezone() + + +@pytest.fixture(autouse=True) +def _utc(monkeypatch): + """Pin UTC so the long simulations do not depend on the machine's zone; the DST hour has its own test.""" + monkeypatch.setenv("TZ", "UTC") + tzset() + yield + monkeypatch.undo() + tzset() + + +def _name(when): + return Path(f"backup-sqlite-{when.strftime('%Y-%m-%d_%H-%M-%S')}.zip") + + +def _taken(path): + return datetime.strptime(re.search(r"(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})", path.name).group(1), "%Y-%m-%d_%H-%M-%S").astimezone() + + +def _tier_width_hours(age_hours): + start = 0.0 + for count, interval in backup.HANOI_TIERS: + width = interval.total_seconds() / 3600 + if age_hours < start + count * width: + return width + start += count * width + return None + + +def _simulate(days, skip=range(0)): + """Run the job's rotation after every hourly backup (with jitter); yield (now, kept files) after each run.""" + rng = random.Random(1) + start = START.astimezone() # in the pinned time zone + files = [] + for hour in range(24 * days): + if hour in skip: + continue + now = start + timedelta(hours=hour, seconds=rng.randint(0, 90)) + files.append(_name(now)) + deleted = set(backup.hanoi_rotation(files, now)) + files = [f for f in files if f not in deleted] + yield now, files + + +def test_months_of_hourly_backups_keep_24_to_34_files_reaching_64_to_85_days(): + counts, oldest = [], [] + for hour, (now, files) in enumerate(_simulate(days=120)): + if hour >= 24 * 90: + counts.append(len(files)) + oldest.append((now - min(map(_taken, files))) / timedelta(days=1)) + + # 34 is the bound: each tier's age range touches at most one bucket more than its count (24 + 10) + assert 24 <= min(counts) and max(counts) <= sum(count + 1 for count, _ in backup.HANOI_TIERS) == 34 + assert 64 <= min(oldest) and max(oldest) <= 85.5 + + +def test_the_gap_between_kept_backups_never_exceeds_the_bucket_width_of_their_tier(): + for hour, (now, files) in enumerate(_simulate(days=100)): + if hour < 24 * 90: + continue + ages = sorted((now - _taken(f)) / timedelta(hours=1) for f in files) + assert ages[:7] == pytest.approx([0, 1, 2, 3, 4, 5, 6], abs=0.05), f"recent hours missing at {now}: {ages[:7]}" + for younger, older in zip(ages, ages[1:]): + assert older - younger <= _tier_width_hours(older) * 1.05, f"gap {younger:.1f}h -> {older:.1f}h at {now}" + + +def test_an_outage_does_not_open_a_gap_in_the_backups_taken_before_it(): + *_, (now, files) = _simulate(days=50, skip=range(24 * 40, 24 * 43)) + ages = sorted((now - _taken(f)) / timedelta(hours=1) for f in files) + before_outage = [age for age in ages if age > 24 * 10] + for younger, older in zip(before_outage, before_outage[1:]): + assert older - younger <= _tier_width_hours(older) * 1.05 + + +def test_the_hour_a_dst_change_repeats_keeps_one_backup_per_hour(tmp_path, monkeypatch): + """Names carry local time without an offset: 02:30 CEST and 02:30 CET share a name pattern, and only + the file's modification time tells them apart. Parsed as one instant, a backup looked an hour off.""" + monkeypatch.setenv("TZ", "Europe/Berlin") + tzset() + start = datetime(2026, 10, 24, 18, 0, 20).astimezone() # CEST; clocks go back at 03:00 on the 25th + files = [] + for hour in range(18): + # A different second each run: two runs at the same second of the repeated hour would share a name + now = (start + timedelta(hours=hour, seconds=hour)).astimezone() + path = tmp_path / _name(now).name + path.write_bytes(b"") + os.utime(path, (now.timestamp(), now.timestamp())) + files.append(path) + deleted = set(backup.hanoi_rotation(files, now)) + files = [f for f in files if f not in deleted] + + if hour >= 6: + ages = sorted((now - backup.backup_taken_at(f, datetime.strptime(f.name[14:33], "%Y-%m-%d_%H-%M-%S"))) / timedelta(hours=1) for f in files) + assert ages[:7] == pytest.approx([0, 1, 2, 3, 4, 5, 6], abs=0.01), f"at {now}: {ages[:7]}" + + +def test_future_dated_and_foreign_files_are_never_deleted(): + now = START.astimezone() + timedelta(days=200) + future = _name(now + timedelta(hours=3)) + foreign = Path("backup-notes.zip") + old = _name(now - timedelta(days=150)) + + deleted = backup.hanoi_rotation([future, foreign, old], now) + + assert deleted == [old] + + +# --- the job ------------------------------------------------------------------------------------ + + +class _FakeBackup(ModuleType): + def __init__(self): + super().__init__("backup") + self.backups = [] + self.DatabaseLockBusy = type("DatabaseLockBusy", (Exception,), {}) + self.hanoi_rotation = backup.hanoi_rotation + self.acquire_db_lock = lambda *args, **kwargs: 41 + self.release_db_lock = lambda handle: None + self.update_cache_file = lambda *args, **kwargs: "" + + def backup_database(self, current_time, db, backup_dir): + self.backups.append(current_time) + backup_dir.joinpath(_name(current_time).name).write_bytes(b"") + return db, None + + +def _run_job(tmp_path, monkeypatch, last_backup, existing=()): + fake = _FakeBackup() + backup_dir = tmp_path / "backups" + backup_dir.mkdir(exist_ok=True) + for when in existing: + backup_dir.joinpath(_name(when).name).write_bytes(b"") + job = SimpleNamespace( + get_cache=lambda name: dumps({"date": last_backup.isoformat()}).encode(), + db=SimpleNamespace(get_metadata=lambda: {"scheduler_first_start": False}), + ) + monkeypatch.setitem(sys.modules, "backup", fake) + monkeypatch.setitem(sys.modules, "jobs", SimpleNamespace(Job=lambda *args: job)) + monkeypatch.setitem(sys.modules, "logger", SimpleNamespace(getLogger=logging.getLogger)) + monkeypatch.setitem(sys.modules, "Database", SimpleNamespace(Database=None)) + for key, value in {"BACKUP_SCHEDULE": "hanoi", "BACKUP_DIRECTORY": str(backup_dir), "BACKUP_ROTATION": "1"}.items(): + monkeypatch.setenv(key, value) + monkeypatch.delenv("FORCE_BACKUP", raising=False) + monkeypatch.delenv("USE_BACKUP", raising=False) + with pytest.raises(SystemExit) as exit_info: + run_path(str(_BACKUP / "jobs" / "backup-data.py"), run_name="__main__") + return exit_info.value.code, fake, sorted(p.name for p in backup_dir.iterdir()) + + +def test_the_previous_hourly_backup_does_not_skip_this_run(tmp_path, monkeypatch): + # The last run started an hour ago and its backup ended 50 seconds later: the old check skipped this run + last = datetime.now().astimezone() - timedelta(minutes=59, seconds=10) + + status, fake, _ = _run_job(tmp_path, monkeypatch, last) + + assert status == 0 + assert len(fake.backups) == 1 + + +def test_a_backup_taken_minutes_ago_skips_this_run(tmp_path, monkeypatch): + last = datetime.now().astimezone() - timedelta(minutes=10) + + status, fake, _ = _run_job(tmp_path, monkeypatch, last) + + assert status == 0 + assert fake.backups == [] + + +def test_the_job_applies_hanoi_rotation_and_ignores_backup_rotation(tmp_path, monkeypatch): + now = datetime.now().astimezone() + hourly = [now - timedelta(hours=hours) for hours in range(1, 6)] + too_old = now - timedelta(days=120) + + status, _, left = _run_job(tmp_path, monkeypatch, now - timedelta(hours=2), existing=[*hourly, too_old]) + + assert status == 0 + assert _name(too_old).name not in left + assert all(_name(when).name in left for when in hourly), "BACKUP_ROTATION=1 must not apply to hanoi" + assert len(left) == 6 # the five hourly backups and the one this run took