# 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 <app_id> --name <archive_name>
BACKUP_CORE_ONLY=1 yunohost backup create --apps nextcloud --name <archive_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 <archive_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/<name>`).

---

## 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/<id>/runs
POST   /api/v1/jobs/<id>/run
GET    /api/v1/running
GET    /api/v1/archives
GET    /api/v1/archives/<name>/info
DELETE /api/v1/archives/<name>
GET    /api/v1/archives/<name>/download
GET    /api/v1/archives/<name>/info-json-download
POST   /api/v1/archives/<name>/restore
GET    /api/v1/archives/<name>/restore/status
POST   /api/v1/archives/upload/start
POST   /api/v1/archives/upload/<id>/chunk/<n>
POST   /api/v1/archives/upload/<id>/finish
DELETE /api/v1/archives/upload/<id>
```

---

## 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_<name>_ed25519 -N "" -C "backupmanager@<dest>"
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 ✅*
