# Cahier des charges — `backupmanager_ynh` **Version 5.0 — 2026-07-17** ## 1. Vision générale Application YunoHost centralisant toutes les sauvegardes d'un serveur, s'appuyant au maximum sur les outils YunoHost natifs, extensible en écosystème fédéré multi-instances. **Périmètre :** Apps YNH · Système YNH · Répertoires custom · MySQL · PostgreSQL ``` ┌──────────────┐ API ┌──────────────┐ API ┌──────────────┐ │ Instance A │◄─────►│ Instance B │◄─────►│ Instance C │ │ (maître) │ │ (nœud) │ │ (nœud) │ └──────────────┘ └──────────────┘ └──────────────┘ N'importe quelle instance peut être "maître" ``` --- ## 2. Décisions clés | Sujet | Décision | |---|---| | Langage | Python 3 + Flask | | Scheduler | APScheduler + SQLite | | DB | SQLite + SQLAlchemy | | Frontend | Jinja2 + TailwindCSS CDN | | Proxy/Service | Nginx + systemd YunoHost natifs | | Archives | `/home/yunohost.backup/archives/` | | Nommage | `jerry_nocodb_20260508.tar` | | Auth dashboard | SSO YunoHost — admins uniquement | | Auth API | `/api` ouvert SSOwat + token `X-BackupManager-Key` | | Transfert | HTTP chunked (fédération) + SSH/rsync (destinations SSH) | | Restauration | Complète : fichiers + user système + systemd + DB | | Destinations | Many-to-many : un job peut cibler plusieurs destinations | --- ## 3. Structure du dépôt ``` backupmanager_ynh/ ├── manifest.toml ├── scripts/ │ ├── _common.sh │ ├── install / remove / backup / restore / upgrade ├── conf/ │ ├── nginx.conf │ ├── systemd.service │ ├── app.conf.j2 │ └── sudoers ├── sources/ │ ├── app.py # Bootstrap Flask + enregistrement blueprints │ ├── scheduler.py # APScheduler │ ├── db.py # SQLAlchemy (Job, Run, Destination, Setting, …) │ ├── retention.py # Moteur count/daily/gfs + apply_ssh_retention │ ├── notifications.py # Email SMTP (succès/erreur) │ ├── helpers.py # read_archive_info, get_ynh_apps │ ├── blueprints/ │ │ ├── jobs.py # Dashboard local, CRUD jobs, archives, restauration │ │ ├── destinations.py # CRUD destinations SSH │ │ ├── network.py # Fédération, push/pull inter-instances │ │ ├── settings.py # Paramètres SMTP, export/import config JSON │ │ ├── api.py # API REST v1 (auth par token) │ │ └── overview.py # Vue globale des archives (local + SSH + instances) │ ├── jobs/ │ │ ├── ynh_backup.py # Point d'entrée execute_job, ynh_app, ynh_system │ │ ├── custom_dir.py # tar + rsync chemins libres │ │ ├── db_dump.py # mysqldump / pg_dump │ │ ├── transfer.py # rsync SSH + push HTTP chunked │ │ └── utils.py # sudo_exists/getsize/listdir/rm/rm_archive │ ├── federation/ │ │ └── client.py # FederationClient + sync_instance │ ├── tests/ │ │ └── test_retention.py # Tests pytest (count / daily / GFS) │ └── templates/ │ ├── base.html │ ├── dashboard_local.html │ ├── dashboard_network.html │ ├── job_form.html │ ├── job_history.html │ ├── archives.html │ ├── archives_overview.html # Vue globale des archives │ ├── restore_confirm.html │ ├── settings.html │ ├── destinations.html │ ├── remote_instances.html │ ├── remote_instance_form.html │ └── federation.html ├── v3/ │ └── apps.json # Catalogue YunoHost personnalisé (version disponible) └── doc/ ``` --- ## 4. Modèle de données SQLite ```sql -- Jobs de sauvegarde CREATE TABLE jobs ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, type TEXT NOT NULL, -- ynh_app|ynh_system|custom_dir|mysql|postgresql config_json TEXT, cron_expr TEXT NOT NULL, -- ex: "0 3 * * 1" ou "" (manuel uniquement) retention_mode TEXT NOT NULL, -- count|daily|gfs retention_value INTEGER NOT NULL, retention_gfs_config TEXT, -- JSON {"daily":N,"weekly":M,"monthly":P} enabled BOOLEAN DEFAULT 1, core_only BOOLEAN DEFAULT 0, created_at DATETIME, updated_at DATETIME ); -- Relation many-to-many Job ↔ Destination CREATE TABLE job_destinations ( id INTEGER PRIMARY KEY, job_id INTEGER REFERENCES jobs(id) ON DELETE CASCADE, dest_type TEXT NOT NULL, -- ssh | instance dest_id INTEGER, -- FK → destinations.id (si ssh) instance_id INTEGER -- FK → remote_instances.id (si instance) ); -- Destinations de transfert SSH/rsync CREATE TABLE destinations ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, host TEXT NOT NULL, port INTEGER DEFAULT 22, user TEXT NOT NULL DEFAULT 'root', remote_path TEXT NOT NULL, key_name TEXT, -- fichier clé dans data_dir/keys/ enabled BOOLEAN DEFAULT 1, created_at DATETIME ); -- Historique des exécutions (backup ET restauration) CREATE TABLE runs ( id INTEGER PRIMARY KEY, job_id INTEGER REFERENCES jobs(id), started_at DATETIME, finished_at DATETIME, status TEXT, -- running|success|warning|error log_text TEXT, -- préfixé [RESTAURATION] si restauration archive_name TEXT, size_bytes INTEGER ); -- Paramètres SMTP et notifications CREATE TABLE settings ( key TEXT PRIMARY KEY, -- smtp_host, smtp_port, smtp_user, … value TEXT NOT NULL DEFAULT '' ); -- Instances distantes enregistrées (fédération) CREATE TABLE remote_instances ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, -- ex: "tom" url TEXT NOT NULL, -- https://tom.domaine.fr api_key TEXT NOT NULL, last_seen DATETIME, status TEXT, -- online|offline|error created_at DATETIME ); -- Cache états distants CREATE TABLE remote_runs ( id INTEGER PRIMARY KEY, instance_id INTEGER REFERENCES remote_instances(id), job_id INTEGER, job_name TEXT, job_type TEXT, last_run_at DATETIME, last_status TEXT, last_archive_name TEXT, last_size_bytes INTEGER ); -- Transferts chunked en cours CREATE TABLE uploads ( upload_id TEXT PRIMARY KEY, -- uuid4 filename TEXT, total_size INTEGER, chunk_size INTEGER, chunks_received INTEGER DEFAULT 0, checksum TEXT, -- SHA256 started_at DATETIME, status TEXT -- pending|in_progress|complete|error ); ``` --- ## 5. Types de jobs ### `ynh_app` ```bash yunohost backup create --apps --name BACKUP_CORE_ONLY=1 yunohost backup create --apps nextcloud --name ``` ```json { "app_id": "nocodb", "core_only": false } ``` Le nom du job est auto-rempli depuis le label de l'application YunoHost sélectionnée. ### `ynh_system` ```bash yunohost backup create --system [hook1 hook2 …] --name ``` ```json { "hooks": ["conf_nginx", "conf_ssowat"] } ``` Les hooks système disponibles sont listés dynamiquement. La liste vide déclenche une sauvegarde système complète. ### `custom_dir` ```json { "source_path": "/opt/hermes-agent", "excludes": ["cache/", "logs/", "*.tmp"], "restore": { "system_user": { "name": "hermes-agent", "home": "/opt/hermes-agent", "shell": "/bin/false" }, "systemd_service": { "name": "hermes-agent", "service_file": "/opt/hermes-agent/hermes-agent.service" }, "permissions": { "owner": "hermes-agent:hermes-agent", "mode": "750" }, "post_restore_commands": ["systemctl restart hermes-agent"] } } ``` **Format archive compatible YunoHost :** ``` jerry_hermes-agent_20260508.tar ├── backup.csv # requis YNH ├── backup_info.json # métadonnées BackupManager └── data/custom/opt/hermes-agent/... jerry_hermes-agent_20260508.info.json # SÉPARÉ hors tar, requis YNH ``` ### `mysql` ```json { "database": "mabase", "user": "mabase_user" } ``` ### `postgresql` ```json { "database": "mabase", "user": "postgres" } ``` --- ## 6. Rétention La rétention est appliquée **après chaque backup réussi**, localement et sur chaque destination. Les échecs de suppression sont loggés explicitement dans le run (plus de silence silencieux). ### Mode `count` ``` → Garde les N dernières archives par job → Supprime les plus anciennes au-delà de N ``` ### Mode `daily` — fenêtre glissante ``` retention_value: 30 → 1 archive par jour sur les 30 derniers jours → Fenêtre glissante : toute archive > J-30 supprimée → Doublons du même jour éliminés (garde le plus récent) ``` ### Mode `gfs` — Grandfather-Father-Son ```json { "daily": 7, "weekly": 4, "monthly": 12 } ``` - **Fils** : conserve les N archives les plus récentes - **Père** : conserve 1 archive par semaine (la plus récente) sur M semaines - **Grand-Père** : conserve 1 archive par mois (la plus récente) sur P mois - Une archive peut satisfaire plusieurs niveaux simultanément ### Rétention distante SSH Après chaque transfert rsync SSH réussi, la même politique est appliquée sur le serveur distant via SSH (`find` pour lister, `rm -f` pour supprimer). Les échecs de connexion ou de suppression sont loggés sans bloquer le job. ### Rétention distante instance fédérée Après chaque push HTTP chunked, la rétention est appliquée via l'API REST de l'instance distante (`DELETE /api/v1/archives/`). --- ## 7. Nommage des archives **Format :** `{instance}_{label}_{YYYYMMDD}.tar` ``` jerry_nocodb_20260508.tar jerry_nextcloud_20260508.tar jerry_system_20260508.tar jerry_hermes-agent_20260508.tar jerry_mysql_mabase_20260508.tar ``` Si le nom du jour existe déjà (backup manuel + automatique le même jour), un suffixe est ajouté : `jerry_nextcloud_20260508_2.tar`. **`backup_info.json` embarqué dans le tar :** ```json { "instance_name": "jerry", "instance_url": "https://jerry.mondomaine.fr", "type": "ynh_app", "created_at": "2026-05-08T03:00:00", "backupmanager_version": "1.0.0" } ``` --- ## 8. Destinations (many-to-many) Un job peut avoir **plusieurs destinations** : aucune (local uniquement), une ou plusieurs SSH, une ou plusieurs instances fédérées, ou toute combinaison. Après chaque backup réussi : 1. Rétention locale appliquée 2. Pour chaque destination dans l'ordre : - Transfert (rsync SSH ou HTTP chunked) - Rétention distante appliquée sur cette destination - Log intermédiaire persisté en base après chaque étape (checkpoint) --- ## 9. Intégration YunoHost | Outil YNH | Usage | |---|---| | `yunohost backup create` | Jobs ynh_app et ynh_system | | `yunohost backup restore` | Restauration webadmin | | `yunohost app list --output-as json` | Formulaire job | | `/home/yunohost.backup/archives/` | Toutes les archives | | Nginx + systemd + SSOwat + Let's Encrypt | Infra app | **SSOwat (`manifest.toml`) :** ```toml [resources.permissions.main] url = "/" allowed = "admins" [resources.permissions.api] url = "/api" allowed = "visitors" # sécurisé par token Flask auth_header = false protected = true ``` **Sudoers :** les commandes autorisées couvrent `yunohost backup`, `mysqldump`, `pg_dump`, `stat`, `find`, `rsync`, `tar`, `rm`, `mkdir`, `chown`, `chmod`, `useradd`, `systemctl`. --- ## 10. API REST Tous les endpoints protégés par `X-BackupManager-Key`. ``` GET /api/v1/health GET /api/v1/summary GET /api/v1/jobs GET /api/v1/jobs//runs POST /api/v1/jobs//run GET /api/v1/running GET /api/v1/archives GET /api/v1/archives//info DELETE /api/v1/archives/ GET /api/v1/archives//download GET /api/v1/archives//info-json-download POST /api/v1/archives//restore GET /api/v1/archives//restore/status POST /api/v1/archives/upload/start POST /api/v1/archives/upload//chunk/ POST /api/v1/archives/upload//finish DELETE /api/v1/archives/upload/ ``` --- ## 11. Transfert inter-instances ### HTTP Chunked (fédération) ``` Chunks : 50 MB | Reprise : upload_id SQLite | Vérif : SHA256 | Transport : HTTPS ``` ### SSH/rsync (destinations) ```bash ssh-keygen -t ed25519 -f $data_dir/keys/dest__ed25519 -N "" -C "backupmanager@" rsync -az -e "ssh -i $key -p $port" archive.tar archive.info.json user@host:/remote/path/ ``` --- ## 12. Restauration assistée | Action | Dashboard BM | Webadmin YNH | |---|---|---| | App YNH | ✅ (via YNH) | ✅ natif | | Système YNH | ✅ (via YNH) | ✅ natif | | Fichiers custom_dir | ✅ complet | ✅ partiel | | User système | ✅ `useradd` | ❌ | | Service systemd | ✅ `systemctl` | ❌ | | Permissions | ✅ `chown/chmod` | ❌ | | Post-restore commands | ✅ | ❌ | | MySQL / PostgreSQL | ✅ | ❌ | | Instance distante | ✅ | ❌ | --- ## 13. Sécurité | Élément | Mesure | |---|---| | Dashboard | SSO YunoHost admins | | Token API | `secrets.token_hex(32)`, hashé bcrypt | | Clé SSH | ed25519, `$data_dir/keys/`, permissions `600` | | Credentials DB | Jamais en clair, config protégée (`chmod 600`) | | Logs | Sans credentials | | Inter-instances | HTTPS obligatoire | | sudo_rm | Vérifie le code retour, logue les échecs explicitement | --- ## 14. Phases de développement ### Phase 1 — MVP local ✅ - [x] manifest.toml, install, remove, nginx, systemd - [x] Flask + SQLite + APScheduler - [x] Jobs ynh_app et ynh_system - [x] Rétention count et daily - [x] Dashboard local (jobs + historique + Run now) ### Phase 2 — Périmètre complet ✅ (testé VPS 2026-05-09) - [x] Jobs custom_dir (exclusions + format YNH compatible) - [x] Jobs mysql et postgresql - [x] Restauration complète custom_dir, mysql, postgresql, ynh_app, ynh_system - [x] Restaurations tracées dans l'historique du job - [x] Destinations rsync SSH - [x] Transfert automatique post-backup - [x] Nommage unique (suffixe _2, _3… si doublon du jour) - [x] Notifications email SMTP (succès/erreur) - [x] Liste BDD live dans le formulaire (mysql/postgresql) - [x] Accès archives root-owned via sudo (stat/find/tar/rsync) ### Phase 3 — Fédération ✅ (testé VPS 2026-05-10) - [x] Modèles DB RemoteInstance / RemoteRun / Upload - [x] API REST complète (/summary, /archives, /restore, upload chunked, /running) - [x] UI instances distantes (liste, ajout, édition, suppression, test, sync) - [x] FederationClient + sync_instance - [x] Dashboard réseau (vue agrégée locale + distante avec statuts) - [x] Push archive HTTP chunked (sha256 + reprise upload_id) - [x] Pull dernière archive d'un job distant + .info.json - [x] Lancer un job sur instance distante depuis le dashboard réseau - [x] Token API affiché dans les Paramètres + URL instance - [x] Rétention distante après push HTTP chunked ### Phase 3 bis — Refactoring & corrections ✅ (2026-05-10) - [x] Découpage app.py → 5 blueprints Flask + helpers.py - [x] Fix : `sudo cat` non autorisé → remplacé par `sudo rsync → open → sudo rm` - [x] Fix : .info.json non rapatrié → `finally` avec `sudo rm -rf` - [x] Tous les templates mis à jour (`url_for('blueprint.fonction')`) ### Phase 4 — Refonte UI ✅ (2026-05-10) - [x] Navigateur d'archives `/archives` — tableau filtrable, actions Restaurer · Pousser · Télécharger · Supprimer - [x] Barre d'activité sticky — polling `/api/v1/running` toutes les 5 s, rechargement auto - [x] Boutons unifiés — 4 classes CSS (`btn-primary/secondary/ghost/danger`) - [x] Navigation simplifiée — Dashboard · Archives · Paramètres - [x] Dashboard home — colonne Transfert, section Serveurs fédérés - [x] Paramètres multi-onglets — Destinations · Instances · Configuration - [x] Icône application — favicon + logo navbar ### Phase 5 — Consolidation ✅ (2026-07-17) - [x] Rétention GFS (Grandfather-Father-Son : daily/weekly/monthly) - [x] Export/import configuration JSON (jobs, destinations, instances, SMTP) - [x] Tests automatisés pytest — rétention count/daily/GFS (17 tests) - [x] Destinations multiples par job (many-to-many : SSH + instances combinables) - [x] Sélection des hooks système pour les jobs ynh_system - [x] Auto-remplissage du nom depuis le label de l'app YunoHost sélectionnée - [x] Logs intermédiaires persistés après chaque étape (checkpoint en base) - [x] Fix rétention silencieuse : `sudo_rm` vérifie le code retour, échecs loggés - [x] Rétention distante SSH : après chaque transfert rsync, nettoyage via SSH - [x] Vue globale des archives `/overview` : local + SSH + instances, suppression manuelle, rétention à la demande ### Phase 6 — Qualité & opérations ✅ (2026-07-17) - [x] Vue globale par job avec onglets (par job / par destination) — indicateur de réplication coloré - [x] Collecte parallèle des destinations dans la vue globale (`ThreadPoolExecutor`) - [x] Fix timeout instances fédérées : `(connect=5s, read=15s)` au lieu de `15s` global - [x] Fix `core_only` : `BACKUP_CORE_ONLY` passé via `env_keep` sudoers (plus de `sudo env`) - [x] Fix listage SSH : remplacement de `find -printf` (GNU only) par `find + stat` portable - [x] Catalogue YunoHost personnalisé (`v3/apps.json`) — mises à jour détectables depuis l'interface web ### En cours / À venir - [ ] Script backup/restore de l'app pour YNH (SQLite + clés SSH) - [ ] Pagination de l'historique des runs - [ ] Notifications Gotify / Matrix (en plus de SMTP) --- ## 15. Notes techniques - **Threads APScheduler** : `app = current_app._get_current_object()` avant le thread, `with app.app_context()` dans le thread - **API auth** : scoped au blueprint via `@bp.before_request` (pas de filtre global) - **sudo rsync** : crée des fichiers `/tmp` owned root → cleanup via `sudo rm -rf` (sudoers) - **Runs bloqués** : nettoyés toutes les heures par APScheduler (> 6h → status error) - **Migration SQLite** : colonnes ajoutées à chaud via `ALTER TABLE` dans `app.py` au démarrage - **Listage SSH** : `cd {dir} && find . -maxdepth 1 -name '*.tar' | stat -c%s` — portable (pas de `find -printf` absent sur BusyBox/BSD) - **core_only** : variable `BACKUP_CORE_ONLY=1` transmise via `subprocess(env=)` + `env_keep` dans sudoers (pas via `sudo env` non autorisé) - **Vue globale** : collecte des destinations en parallèle via `ThreadPoolExecutor` — une destination lente ne bloque pas les autres --- ## 16. Processus de release ### Configuration initiale (une seule fois par serveur YunoHost) 1. Créer `/etc/yunohost/apps_catalog.yml` : ```yaml - id: default url: https://app.yunohost.org/default - id: backupmanager-hansen url: https://git.hansen.tl/YunoHost-Apps/backupmanager_ynh/raw/main ``` 2. Rafraîchir le cache : `sudo yunohost tools update apps` YunoHost construit l'URL du catalogue en ajoutant `/v3/apps.json` à l'URL de base, soit : `https://git.hansen.tl/YunoHost-Apps/backupmanager_ynh/raw/main/v3/apps.json` ### À chaque release 1. Bumper la version dans **deux fichiers** (ex : `1.1~ynh1` → `1.2~ynh1`) : - `manifest.toml` → champ `version` - `v3/apps.json` → champ `manifest.version` 2. Committer et pousser sur `main` 3. YunoHost détecte la différence lors du prochain rafraîchissement (quotidien automatique ou manuel avec `sudo yunohost tools update apps`) et propose la mise à jour dans l'interface web 4. L'upgrade récupère le code depuis le git et installe la nouvelle version --- *backupmanager_ynh — CDC v5.0 — 2026-07-17 | Phase 1 ✅ | Phase 2 ✅ | Phase 3 ✅ | Phase 3bis ✅ | Phase 4 ✅ | Phase 5 ✅*