1285 lines
37 KiB
Markdown
1285 lines
37 KiB
Markdown
# Sauvegarde de la base utilisateurs sur S3 — plan d'implémentation
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Sauvegarder `users.sqlite` toutes les heures vers un stockage S3-compatible, chiffrée, avec rotation multi-paliers et restauration manuelle.
|
|
|
|
**Architecture:** Un module `src/backup/` découpé par responsabilité (config, nommage, rotation pure, snapshot SQLite, chiffrement, stockage S3, orchestration, CLI). Un timer systemd lance `python -m src.backup backup` chaque heure. La logique de rétention est une fonction pure testée isolément ; l'orchestration est testée contre un faux stockage en mémoire ; le wrapper S3 réel est testé avec `moto`.
|
|
|
|
**Tech Stack:** Python 3.10+, `sqlite3` (API de backup en ligne, base en WAL), `gzip`, `cryptography` (Fernet), `boto3` (S3 via `endpoint_url`), `argparse`, `pytest`, `moto[s3]`.
|
|
|
|
## Global Constraints
|
|
|
|
- Python : `requires-python >= 3.10`. Pas de syntaxe > 3.10.
|
|
- Imports internes toujours préfixés `src.` (ex. `from src.backup import ...`), jamais `backup.` ni `from backup import`.
|
|
- La base est ouverte en mode WAL (`src/auth/db.py:49`) → un snapshot DOIT passer par l'API `sqlite3.Connection.backup()`, jamais par une copie de fichier brute.
|
|
- CLI : suivre le motif de `src/api/tokens_cli.py` — `def main(argv=None, env=None) -> int`, sous-commandes argparse, `if __name__ == "__main__": sys.exit(main())`.
|
|
- Variables d'environnement lues via `os.environ` / argument `env` (jamais d'appel à `load_dotenv()` dans ces modules).
|
|
- Tests sous `tests/`, exécutés par `pytest` (`pythonpath = ["src"]`). Commande : `pytest <chemin>`.
|
|
- Format de clé S3 (verbatim) : `<prefix>/users-<YYYYMMDDTHHMMSSZ>.sqlite.gz.enc`, horodatage UTC.
|
|
- Paliers de rétention (union) : horaire/12 h, 12 h/72 h, quotidien/21 j, mensuel calendaire/12 mois.
|
|
|
|
---
|
|
|
|
## Structure des fichiers
|
|
|
|
| Fichier | Responsabilité |
|
|
| -------------------------------- | ----------------------------------------------------------------------- |
|
|
| `src/backup/__init__.py` | Marqueur de package (vide). |
|
|
| `src/backup/config.py` | `BackupConfig` (dataclass) + `load_config(env)`. |
|
|
| `src/backup/naming.py` | Construire/parser les clés S3 horodatées. |
|
|
| `src/backup/rotation.py` | `select_retained(timestamps, now)` — fonction pure. |
|
|
| `src/backup/crypto.py` | `encrypt`/`decrypt` (Fernet). |
|
|
| `src/backup/snapshot.py` | Snapshot SQLite cohérent + gzip ; décompression + contrôle d'intégrité. |
|
|
| `src/backup/storage.py` | `Storage` (Protocol) + `S3Storage` (boto3). |
|
|
| `src/backup/service.py` | Orchestration : `run_backup`, `list_backups`, `restore`. |
|
|
| `src/backup/cli.py` | Point d'entrée argparse. |
|
|
| `src/backup/__main__.py` | Permet `python -m src.backup`. |
|
|
| `deploy/decpinfo-backup.service` | Unité systemd oneshot. |
|
|
| `deploy/decpinfo-backup.timer` | Timer horaire. |
|
|
| `tests/backup/fakes.py` | `FakeStorage` en mémoire (réutilisé par les tests). |
|
|
| `tests/backup/test_*.py` | Tests par module. |
|
|
|
|
---
|
|
|
|
## Task 1: Dépendances + module de configuration
|
|
|
|
**Files:**
|
|
|
|
- Modify: `pyproject.toml` (ajout deps `boto3`, `cryptography` ; dev `moto[s3]`)
|
|
- Create: `src/backup/__init__.py` (vide)
|
|
- Create: `src/backup/config.py`
|
|
- Test: `tests/backup/test_config.py`
|
|
|
|
**Interfaces:**
|
|
|
|
- Consumes: rien.
|
|
- Produces:
|
|
|
|
```python
|
|
@dataclass(frozen=True)
|
|
class BackupConfig:
|
|
db_path: Path
|
|
bucket: str
|
|
prefix: str
|
|
endpoint_url: str | None
|
|
access_key: str
|
|
secret_key: str
|
|
encryption_key: str
|
|
|
|
def load_config(env: Mapping[str, str]) -> BackupConfig
|
|
```
|
|
|
|
- [ ] **Step 1: Ajouter les dépendances**
|
|
|
|
Dans `pyproject.toml`, ajouter à la liste `dependencies` (après `"marshmallow>=3.20.0",`) :
|
|
|
|
```toml
|
|
"boto3",
|
|
"cryptography",
|
|
```
|
|
|
|
Et dans `[dependency-groups]` `dev`, après `"openpyxl",` :
|
|
|
|
```toml
|
|
"moto[s3]",
|
|
```
|
|
|
|
Puis installer :
|
|
|
|
```bash
|
|
rtk pip install -e . --group=dev
|
|
```
|
|
|
|
- [ ] **Step 2: Écrire le test qui échoue**
|
|
|
|
Créer `tests/backup/__init__.py` (vide) puis `tests/backup/test_config.py` :
|
|
|
|
```python
|
|
from pathlib import Path
|
|
|
|
from src.backup.config import BackupConfig, load_config
|
|
|
|
|
|
def test_load_config_reads_env():
|
|
env = {
|
|
"USERS_DB_PATH": "users.sqlite",
|
|
"S3_BUCKET": "decp-backups",
|
|
"S3_BACKUP_PREFIX": "backups",
|
|
"S3_ENDPOINT_URL": "https://s3.example.net",
|
|
"S3_ACCESS_KEY_ID": "AK",
|
|
"S3_SECRET_ACCESS_KEY": "SK",
|
|
"BACKUP_ENCRYPTION_KEY": "key123",
|
|
}
|
|
cfg = load_config(env)
|
|
assert cfg == BackupConfig(
|
|
db_path=Path("users.sqlite"),
|
|
bucket="decp-backups",
|
|
prefix="backups",
|
|
endpoint_url="https://s3.example.net",
|
|
access_key="AK",
|
|
secret_key="SK",
|
|
encryption_key="key123",
|
|
)
|
|
|
|
|
|
def test_load_config_defaults_prefix_and_empty_endpoint():
|
|
env = {
|
|
"USERS_DB_PATH": "users.sqlite",
|
|
"S3_BUCKET": "b",
|
|
"S3_ENDPOINT_URL": "",
|
|
"S3_ACCESS_KEY_ID": "AK",
|
|
"S3_SECRET_ACCESS_KEY": "SK",
|
|
"BACKUP_ENCRYPTION_KEY": "k",
|
|
}
|
|
cfg = load_config(env)
|
|
assert cfg.prefix == "backups"
|
|
assert cfg.endpoint_url is None
|
|
```
|
|
|
|
- [ ] **Step 3: Lancer le test (échec attendu)**
|
|
|
|
Run: `pytest tests/backup/test_config.py -v`
|
|
Expected: FAIL — `ModuleNotFoundError: No module named 'src.backup'`
|
|
|
|
- [ ] **Step 4: Implémenter**
|
|
|
|
Créer `src/backup/__init__.py` vide, puis `src/backup/config.py` :
|
|
|
|
```python
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BackupConfig:
|
|
db_path: Path
|
|
bucket: str
|
|
prefix: str
|
|
endpoint_url: str | None
|
|
access_key: str
|
|
secret_key: str
|
|
encryption_key: str
|
|
|
|
|
|
def load_config(env: Mapping[str, str]) -> BackupConfig:
|
|
return BackupConfig(
|
|
db_path=Path(env["USERS_DB_PATH"]),
|
|
bucket=env["S3_BUCKET"],
|
|
prefix=env.get("S3_BACKUP_PREFIX", "backups"),
|
|
endpoint_url=env.get("S3_ENDPOINT_URL") or None,
|
|
access_key=env["S3_ACCESS_KEY_ID"],
|
|
secret_key=env["S3_SECRET_ACCESS_KEY"],
|
|
encryption_key=env["BACKUP_ENCRYPTION_KEY"],
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 5: Lancer le test (succès attendu)**
|
|
|
|
Run: `pytest tests/backup/test_config.py -v`
|
|
Expected: PASS (2 tests)
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add pyproject.toml src/backup/__init__.py src/backup/config.py tests/backup/__init__.py tests/backup/test_config.py
|
|
git commit -m "feat(backup): config et dépendances pour la sauvegarde S3"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Nommage des clés S3
|
|
|
|
**Files:**
|
|
|
|
- Create: `src/backup/naming.py`
|
|
- Test: `tests/backup/test_naming.py`
|
|
|
|
**Interfaces:**
|
|
|
|
- Consumes: rien.
|
|
- Produces:
|
|
|
|
```python
|
|
def make_key(prefix: str, ts: datetime) -> str # "<prefix>/users-<YYYYMMDDTHHMMSSZ>.sqlite.gz.enc"
|
|
def parse_timestamp(key: str) -> datetime # UTC aware ; lève ValueError si non reconnu
|
|
```
|
|
|
|
- [ ] **Step 1: Écrire le test qui échoue**
|
|
|
|
Créer `tests/backup/test_naming.py` :
|
|
|
|
```python
|
|
from datetime import datetime, timezone
|
|
|
|
import pytest
|
|
|
|
from src.backup.naming import make_key, parse_timestamp
|
|
|
|
|
|
def test_make_key_format():
|
|
ts = datetime(2026, 6, 24, 14, 5, 9, tzinfo=timezone.utc)
|
|
assert make_key("backups", ts) == "backups/users-20260624T140509Z.sqlite.gz.enc"
|
|
|
|
|
|
def test_make_key_strips_trailing_slash():
|
|
ts = datetime(2026, 6, 24, 14, 5, 9, tzinfo=timezone.utc)
|
|
assert make_key("backups/", ts).startswith("backups/users-")
|
|
|
|
|
|
def test_roundtrip():
|
|
ts = datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc)
|
|
assert parse_timestamp(make_key("p", ts)) == ts
|
|
|
|
|
|
def test_parse_rejects_unknown_key():
|
|
with pytest.raises(ValueError):
|
|
parse_timestamp("backups/users.sqlite.bak")
|
|
```
|
|
|
|
- [ ] **Step 2: Lancer le test (échec attendu)**
|
|
|
|
Run: `pytest tests/backup/test_naming.py -v`
|
|
Expected: FAIL — `ModuleNotFoundError: No module named 'src.backup.naming'`
|
|
|
|
- [ ] **Step 3: Implémenter**
|
|
|
|
Créer `src/backup/naming.py` :
|
|
|
|
```python
|
|
import re
|
|
from datetime import datetime, timezone
|
|
|
|
_TS_FMT = "%Y%m%dT%H%M%SZ"
|
|
_KEY_RE = re.compile(r"users-(\d{8}T\d{6}Z)\.sqlite\.gz\.enc$")
|
|
|
|
|
|
def make_key(prefix: str, ts: datetime) -> str:
|
|
stamp = ts.astimezone(timezone.utc).strftime(_TS_FMT)
|
|
return f"{prefix.rstrip('/')}/users-{stamp}.sqlite.gz.enc"
|
|
|
|
|
|
def parse_timestamp(key: str) -> datetime:
|
|
match = _KEY_RE.search(key)
|
|
if not match:
|
|
raise ValueError(f"clé non reconnue : {key}")
|
|
return datetime.strptime(match.group(1), _TS_FMT).replace(tzinfo=timezone.utc)
|
|
```
|
|
|
|
- [ ] **Step 4: Lancer le test (succès attendu)**
|
|
|
|
Run: `pytest tests/backup/test_naming.py -v`
|
|
Expected: PASS (4 tests)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/backup/naming.py tests/backup/test_naming.py
|
|
git commit -m "feat(backup): nommage horodaté des clés S3"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Rotation (fonction pure)
|
|
|
|
**Files:**
|
|
|
|
- Create: `src/backup/rotation.py`
|
|
- Test: `tests/backup/test_rotation.py`
|
|
|
|
**Interfaces:**
|
|
|
|
- Consumes: rien.
|
|
- Produces:
|
|
|
|
```python
|
|
def select_retained(timestamps: Iterable[datetime], now: datetime) -> set[datetime]
|
|
```
|
|
|
|
Les horodatages sont supposés aware UTC. Renvoie le sous-ensemble à CONSERVER.
|
|
|
|
- [ ] **Step 1: Écrire le test qui échoue**
|
|
|
|
Créer `tests/backup/test_rotation.py` :
|
|
|
|
```python
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from src.backup.rotation import select_retained
|
|
|
|
NOW = datetime(2026, 6, 24, 12, 0, 0, tzinfo=timezone.utc)
|
|
|
|
|
|
def test_most_recent_always_kept():
|
|
ts = [NOW, NOW - timedelta(hours=2)]
|
|
assert NOW in select_retained(ts, NOW)
|
|
|
|
|
|
def test_recent_hours_kept_by_hourly_tier():
|
|
t = NOW - timedelta(hours=3)
|
|
assert t in select_retained([t], NOW)
|
|
|
|
|
|
def test_two_backups_same_hour_keep_newest():
|
|
older = NOW - timedelta(hours=2, minutes=50)
|
|
newer = NOW - timedelta(hours=2, minutes=10)
|
|
retained = select_retained([older, newer], NOW)
|
|
assert newer in retained
|
|
assert older not in retained
|
|
|
|
|
|
def test_daily_tier_keeps_old_daily_backup():
|
|
t = NOW - timedelta(days=10)
|
|
assert t in select_retained([t], NOW)
|
|
|
|
|
|
def test_monthly_tier_keeps_one_per_calendar_month():
|
|
older = NOW - timedelta(days=40) # mois M-2 ou M-1 selon calendrier
|
|
same_month_newer = older + timedelta(days=2)
|
|
retained = select_retained([older, same_month_newer], NOW)
|
|
# même mois calendaire -> seul le plus récent du mois est conservé
|
|
assert same_month_newer in retained
|
|
assert older not in retained
|
|
|
|
|
|
def test_keeps_backup_within_12_months():
|
|
t = NOW - timedelta(days=300)
|
|
assert t in select_retained([t], NOW)
|
|
|
|
|
|
def test_drops_backup_older_than_12_months():
|
|
t = NOW - timedelta(days=400)
|
|
assert t not in select_retained([t], NOW)
|
|
|
|
|
|
def test_future_timestamps_ignored():
|
|
t = NOW + timedelta(hours=1)
|
|
assert t not in select_retained([t], NOW)
|
|
```
|
|
|
|
- [ ] **Step 2: Lancer le test (échec attendu)**
|
|
|
|
Run: `pytest tests/backup/test_rotation.py -v`
|
|
Expected: FAIL — `ModuleNotFoundError: No module named 'src.backup.rotation'`
|
|
|
|
- [ ] **Step 3: Implémenter**
|
|
|
|
Créer `src/backup/rotation.py` :
|
|
|
|
```python
|
|
from collections.abc import Iterable
|
|
from datetime import datetime, timedelta
|
|
|
|
# (période, horizon) pour les paliers à période fixe
|
|
_FIXED_TIERS = (
|
|
(timedelta(hours=1), timedelta(hours=12)), # horaire / 12 h
|
|
(timedelta(hours=12), timedelta(hours=72)), # 12 h / 72 h
|
|
(timedelta(days=1), timedelta(days=21)), # quotidien / 21 j
|
|
)
|
|
_MONTHLY_HORIZON = 12 # mois calendaires
|
|
|
|
|
|
def _select_fixed(timestamps, now, period, horizon):
|
|
cutoff = now - horizon
|
|
buckets: dict[int, datetime] = {}
|
|
for t in timestamps:
|
|
if t > now or t < cutoff:
|
|
continue
|
|
idx = int((now - t) // period)
|
|
if idx not in buckets or t > buckets[idx]:
|
|
buckets[idx] = t
|
|
return set(buckets.values())
|
|
|
|
|
|
def _month_index(d: datetime) -> int:
|
|
return d.year * 12 + (d.month - 1)
|
|
|
|
|
|
def _select_monthly(timestamps, now):
|
|
now_idx = _month_index(now)
|
|
buckets: dict[int, datetime] = {}
|
|
for t in timestamps:
|
|
if t > now:
|
|
continue
|
|
idx = _month_index(t)
|
|
if idx > now_idx or now_idx - idx >= _MONTHLY_HORIZON:
|
|
continue
|
|
if idx not in buckets or t > buckets[idx]:
|
|
buckets[idx] = t
|
|
return set(buckets.values())
|
|
|
|
|
|
def select_retained(timestamps: Iterable[datetime], now: datetime) -> set[datetime]:
|
|
items = list(timestamps)
|
|
retained: set[datetime] = set()
|
|
for period, horizon in _FIXED_TIERS:
|
|
retained |= _select_fixed(items, now, period, horizon)
|
|
retained |= _select_monthly(items, now)
|
|
return retained
|
|
```
|
|
|
|
- [ ] **Step 4: Lancer le test (succès attendu)**
|
|
|
|
Run: `pytest tests/backup/test_rotation.py -v`
|
|
Expected: PASS (8 tests)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/backup/rotation.py tests/backup/test_rotation.py
|
|
git commit -m "feat(backup): rotation multi-paliers (fonction pure)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Chiffrement
|
|
|
|
**Files:**
|
|
|
|
- Create: `src/backup/crypto.py`
|
|
- Test: `tests/backup/test_crypto.py`
|
|
|
|
**Interfaces:**
|
|
|
|
- Consumes: rien.
|
|
- Produces:
|
|
|
|
```python
|
|
def encrypt(data: bytes, key: str) -> bytes
|
|
def decrypt(token: bytes, key: str) -> bytes
|
|
```
|
|
|
|
`key` est une clé Fernet urlsafe-base64 (générée par `Fernet.generate_key().decode()`).
|
|
|
|
- [ ] **Step 1: Écrire le test qui échoue**
|
|
|
|
Créer `tests/backup/test_crypto.py` :
|
|
|
|
```python
|
|
import pytest
|
|
from cryptography.fernet import Fernet
|
|
|
|
from src.backup.crypto import decrypt, encrypt
|
|
|
|
|
|
def test_roundtrip():
|
|
key = Fernet.generate_key().decode()
|
|
data = b"contenu de la base sqlite"
|
|
assert decrypt(encrypt(data, key), key) == data
|
|
|
|
|
|
def test_wrong_key_fails():
|
|
data = b"secret"
|
|
token = encrypt(data, Fernet.generate_key().decode())
|
|
with pytest.raises(Exception):
|
|
decrypt(token, Fernet.generate_key().decode())
|
|
```
|
|
|
|
- [ ] **Step 2: Lancer le test (échec attendu)**
|
|
|
|
Run: `pytest tests/backup/test_crypto.py -v`
|
|
Expected: FAIL — `ModuleNotFoundError: No module named 'src.backup.crypto'`
|
|
|
|
- [ ] **Step 3: Implémenter**
|
|
|
|
Créer `src/backup/crypto.py` :
|
|
|
|
```python
|
|
from cryptography.fernet import Fernet
|
|
|
|
|
|
def encrypt(data: bytes, key: str) -> bytes:
|
|
return Fernet(key.encode()).encrypt(data)
|
|
|
|
|
|
def decrypt(token: bytes, key: str) -> bytes:
|
|
return Fernet(key.encode()).decrypt(token)
|
|
```
|
|
|
|
- [ ] **Step 4: Lancer le test (succès attendu)**
|
|
|
|
Run: `pytest tests/backup/test_crypto.py -v`
|
|
Expected: PASS (2 tests)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/backup/crypto.py tests/backup/test_crypto.py
|
|
git commit -m "feat(backup): chiffrement Fernet des sauvegardes"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Snapshot SQLite + intégrité
|
|
|
|
**Files:**
|
|
|
|
- Create: `src/backup/snapshot.py`
|
|
- Test: `tests/backup/test_snapshot.py`
|
|
|
|
**Interfaces:**
|
|
|
|
- Consumes: rien.
|
|
- Produces:
|
|
|
|
```python
|
|
def make_snapshot(db_path: Path) -> bytes # snapshot cohérent gzippé
|
|
def write_snapshot(gzipped: bytes, dest: Path) -> None
|
|
def verify_integrity(db_path: Path) -> bool
|
|
```
|
|
|
|
- [ ] **Step 1: Écrire le test qui échoue**
|
|
|
|
Créer `tests/backup/test_snapshot.py` :
|
|
|
|
```python
|
|
import sqlite3
|
|
from pathlib import Path
|
|
|
|
from src.backup.snapshot import make_snapshot, verify_integrity, write_snapshot
|
|
|
|
|
|
def _make_db(path: Path):
|
|
conn = sqlite3.connect(str(path))
|
|
conn.execute("CREATE TABLE t (id INTEGER, val TEXT)")
|
|
conn.execute("INSERT INTO t VALUES (1, 'a'), (2, 'b')")
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def test_snapshot_roundtrip_preserves_rows(tmp_path):
|
|
src = tmp_path / "users.sqlite"
|
|
_make_db(src)
|
|
gzipped = make_snapshot(src)
|
|
restored = tmp_path / "restored.sqlite"
|
|
write_snapshot(gzipped, restored)
|
|
conn = sqlite3.connect(str(restored))
|
|
rows = conn.execute("SELECT id, val FROM t ORDER BY id").fetchall()
|
|
conn.close()
|
|
assert rows == [(1, "a"), (2, "b")]
|
|
|
|
|
|
def test_verify_integrity_ok(tmp_path):
|
|
src = tmp_path / "users.sqlite"
|
|
_make_db(src)
|
|
assert verify_integrity(src) is True
|
|
|
|
|
|
def test_verify_integrity_detects_corruption(tmp_path):
|
|
bad = tmp_path / "bad.sqlite"
|
|
bad.write_bytes(b"this is not a sqlite database")
|
|
assert verify_integrity(bad) is False
|
|
```
|
|
|
|
- [ ] **Step 2: Lancer le test (échec attendu)**
|
|
|
|
Run: `pytest tests/backup/test_snapshot.py -v`
|
|
Expected: FAIL — `ModuleNotFoundError: No module named 'src.backup.snapshot'`
|
|
|
|
- [ ] **Step 3: Implémenter**
|
|
|
|
Créer `src/backup/snapshot.py` :
|
|
|
|
```python
|
|
import gzip
|
|
import sqlite3
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
|
|
def make_snapshot(db_path: Path) -> bytes:
|
|
with tempfile.NamedTemporaryFile(suffix=".sqlite", delete=False) as tmp:
|
|
tmp_path = Path(tmp.name)
|
|
try:
|
|
src = sqlite3.connect(str(db_path))
|
|
try:
|
|
dst = sqlite3.connect(str(tmp_path))
|
|
try:
|
|
src.backup(dst)
|
|
finally:
|
|
dst.close()
|
|
finally:
|
|
src.close()
|
|
return gzip.compress(tmp_path.read_bytes())
|
|
finally:
|
|
tmp_path.unlink(missing_ok=True)
|
|
|
|
|
|
def write_snapshot(gzipped: bytes, dest: Path) -> None:
|
|
dest.write_bytes(gzip.decompress(gzipped))
|
|
|
|
|
|
def verify_integrity(db_path: Path) -> bool:
|
|
try:
|
|
conn = sqlite3.connect(str(db_path))
|
|
except sqlite3.DatabaseError:
|
|
return False
|
|
try:
|
|
row = conn.execute("PRAGMA integrity_check").fetchone()
|
|
return row is not None and row[0] == "ok"
|
|
except sqlite3.DatabaseError:
|
|
return False
|
|
finally:
|
|
conn.close()
|
|
```
|
|
|
|
- [ ] **Step 4: Lancer le test (succès attendu)**
|
|
|
|
Run: `pytest tests/backup/test_snapshot.py -v`
|
|
Expected: PASS (3 tests)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/backup/snapshot.py tests/backup/test_snapshot.py
|
|
git commit -m "feat(backup): snapshot SQLite cohérent et contrôle d'intégrité"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Stockage S3
|
|
|
|
**Files:**
|
|
|
|
- Create: `src/backup/storage.py`
|
|
- Create: `tests/backup/fakes.py`
|
|
- Test: `tests/backup/test_storage_s3.py`
|
|
|
|
**Interfaces:**
|
|
|
|
- Consumes: `BackupConfig` (Task 1).
|
|
- Produces:
|
|
|
|
```python
|
|
class Storage(Protocol):
|
|
def upload_bytes(self, key: str, data: bytes) -> None: ...
|
|
def download_bytes(self, key: str) -> bytes: ...
|
|
def list_keys(self, prefix: str) -> list[str]: ...
|
|
def delete(self, key: str) -> None: ...
|
|
|
|
class S3Storage:
|
|
def __init__(self, bucket, endpoint_url, access_key, secret_key, region="us-east-1")
|
|
@classmethod
|
|
def from_config(cls, config: BackupConfig) -> "S3Storage"
|
|
|
|
# tests/backup/fakes.py
|
|
class FakeStorage: # même interface, dict en mémoire
|
|
objects: dict[str, bytes]
|
|
```
|
|
|
|
- [ ] **Step 1: Écrire le faux stockage (utilisé par les tests suivants)**
|
|
|
|
Créer `tests/backup/fakes.py` :
|
|
|
|
```python
|
|
class FakeStorage:
|
|
def __init__(self):
|
|
self.objects: dict[str, bytes] = {}
|
|
|
|
def upload_bytes(self, key, data):
|
|
self.objects[key] = data
|
|
|
|
def download_bytes(self, key):
|
|
return self.objects[key]
|
|
|
|
def list_keys(self, prefix):
|
|
return [k for k in self.objects if k.startswith(prefix)]
|
|
|
|
def delete(self, key):
|
|
self.objects.pop(key, None)
|
|
```
|
|
|
|
- [ ] **Step 2: Écrire le test S3 qui échoue**
|
|
|
|
Créer `tests/backup/test_storage_s3.py` :
|
|
|
|
```python
|
|
import boto3
|
|
import pytest
|
|
from moto import mock_aws
|
|
|
|
from src.backup.storage import S3Storage
|
|
|
|
BUCKET = "decp-backups"
|
|
|
|
|
|
@pytest.fixture
|
|
def s3_storage():
|
|
with mock_aws():
|
|
boto3.client("s3", region_name="us-east-1").create_bucket(Bucket=BUCKET)
|
|
yield S3Storage(
|
|
bucket=BUCKET,
|
|
endpoint_url=None,
|
|
access_key="testing",
|
|
secret_key="testing",
|
|
)
|
|
|
|
|
|
def test_upload_list_download_delete(s3_storage):
|
|
s3_storage.upload_bytes("backups/a.enc", b"alpha")
|
|
s3_storage.upload_bytes("backups/b.enc", b"beta")
|
|
s3_storage.upload_bytes("autre/c.enc", b"gamma")
|
|
|
|
assert set(s3_storage.list_keys("backups/")) == {"backups/a.enc", "backups/b.enc"}
|
|
assert s3_storage.download_bytes("backups/a.enc") == b"alpha"
|
|
|
|
s3_storage.delete("backups/a.enc")
|
|
assert s3_storage.list_keys("backups/") == ["backups/b.enc"]
|
|
```
|
|
|
|
- [ ] **Step 3: Lancer le test (échec attendu)**
|
|
|
|
Run: `pytest tests/backup/test_storage_s3.py -v`
|
|
Expected: FAIL — `ModuleNotFoundError: No module named 'src.backup.storage'`
|
|
|
|
- [ ] **Step 4: Implémenter**
|
|
|
|
Créer `src/backup/storage.py` :
|
|
|
|
```python
|
|
from typing import Protocol
|
|
|
|
import boto3
|
|
|
|
from src.backup.config import BackupConfig
|
|
|
|
|
|
class Storage(Protocol):
|
|
def upload_bytes(self, key: str, data: bytes) -> None: ...
|
|
def download_bytes(self, key: str) -> bytes: ...
|
|
def list_keys(self, prefix: str) -> list[str]: ...
|
|
def delete(self, key: str) -> None: ...
|
|
|
|
|
|
class S3Storage:
|
|
def __init__(self, bucket, endpoint_url, access_key, secret_key, region="us-east-1"):
|
|
self._bucket = bucket
|
|
self._client = boto3.client(
|
|
"s3",
|
|
endpoint_url=endpoint_url,
|
|
aws_access_key_id=access_key,
|
|
aws_secret_access_key=secret_key,
|
|
region_name=region,
|
|
)
|
|
|
|
@classmethod
|
|
def from_config(cls, config: BackupConfig) -> "S3Storage":
|
|
return cls(
|
|
bucket=config.bucket,
|
|
endpoint_url=config.endpoint_url,
|
|
access_key=config.access_key,
|
|
secret_key=config.secret_key,
|
|
)
|
|
|
|
def upload_bytes(self, key: str, data: bytes) -> None:
|
|
self._client.put_object(Bucket=self._bucket, Key=key, Body=data)
|
|
|
|
def download_bytes(self, key: str) -> bytes:
|
|
resp = self._client.get_object(Bucket=self._bucket, Key=key)
|
|
return resp["Body"].read()
|
|
|
|
def list_keys(self, prefix: str) -> list[str]:
|
|
keys: list[str] = []
|
|
paginator = self._client.get_paginator("list_objects_v2")
|
|
for page in paginator.paginate(Bucket=self._bucket, Prefix=prefix):
|
|
for obj in page.get("Contents", []):
|
|
keys.append(obj["Key"])
|
|
return keys
|
|
|
|
def delete(self, key: str) -> None:
|
|
self._client.delete_object(Bucket=self._bucket, Key=key)
|
|
```
|
|
|
|
- [ ] **Step 5: Lancer le test (succès attendu)**
|
|
|
|
Run: `pytest tests/backup/test_storage_s3.py -v`
|
|
Expected: PASS (1 test)
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/backup/storage.py tests/backup/fakes.py tests/backup/test_storage_s3.py
|
|
git commit -m "feat(backup): wrapper de stockage S3 (boto3) + faux stockage de test"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Orchestration (service)
|
|
|
|
**Files:**
|
|
|
|
- Create: `src/backup/service.py`
|
|
- Test: `tests/backup/test_service.py`
|
|
|
|
**Interfaces:**
|
|
|
|
- Consumes: `BackupConfig` (T1), `make_key`/`parse_timestamp` (T2), `select_retained` (T3), `encrypt`/`decrypt` (T4), `make_snapshot`/`write_snapshot`/`verify_integrity` (T5), `Storage` (T6).
|
|
- Produces:
|
|
|
|
```python
|
|
def run_backup(config: BackupConfig, storage: Storage, now: datetime) -> str # clé créée
|
|
def list_backups(config: BackupConfig, storage: Storage) -> list[tuple[str, datetime]] # trié par date
|
|
def restore(config: BackupConfig, storage: Storage, key: str, now: datetime) -> Path # chemin de la copie de secours
|
|
```
|
|
|
|
- [ ] **Step 1: Écrire le test qui échoue**
|
|
|
|
Créer `tests/backup/test_service.py` :
|
|
|
|
```python
|
|
import sqlite3
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from cryptography.fernet import Fernet
|
|
|
|
from src.backup import service
|
|
from src.backup.config import BackupConfig
|
|
from src.backup.naming import make_key
|
|
from tests.backup.fakes import FakeStorage
|
|
|
|
NOW = datetime(2026, 6, 24, 12, 0, 0, tzinfo=timezone.utc)
|
|
|
|
|
|
def _make_db(path: Path, val: str):
|
|
conn = sqlite3.connect(str(path))
|
|
conn.execute("CREATE TABLE t (val TEXT)")
|
|
conn.execute("INSERT INTO t VALUES (?)", (val,))
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def _config(db_path: Path) -> BackupConfig:
|
|
return BackupConfig(
|
|
db_path=db_path,
|
|
bucket="b",
|
|
prefix="backups",
|
|
endpoint_url=None,
|
|
access_key="AK",
|
|
secret_key="SK",
|
|
encryption_key=Fernet.generate_key().decode(),
|
|
)
|
|
|
|
|
|
def test_run_backup_uploads_key(tmp_path):
|
|
db = tmp_path / "users.sqlite"
|
|
_make_db(db, "v1")
|
|
storage = FakeStorage()
|
|
key = service.run_backup(_config(db), storage, NOW)
|
|
assert key in storage.objects
|
|
assert key == "backups/users-20260624T120000Z.sqlite.gz.enc"
|
|
|
|
|
|
def test_run_backup_applies_rotation(tmp_path):
|
|
db = tmp_path / "users.sqlite"
|
|
_make_db(db, "v1")
|
|
cfg = _config(db)
|
|
storage = FakeStorage()
|
|
# une vieille sauvegarde de >12 mois doit être supprimée par la rotation
|
|
stale = make_key(cfg.prefix, NOW - timedelta(days=400))
|
|
storage.objects[stale] = b"old"
|
|
service.run_backup(cfg, storage, NOW)
|
|
assert stale not in storage.objects
|
|
|
|
|
|
def test_list_backups_sorted(tmp_path):
|
|
db = tmp_path / "users.sqlite"
|
|
_make_db(db, "v1")
|
|
cfg = _config(db)
|
|
storage = FakeStorage()
|
|
storage.objects[make_key(cfg.prefix, NOW)] = b"x"
|
|
storage.objects[make_key(cfg.prefix, NOW - timedelta(hours=1))] = b"x"
|
|
storage.objects["backups/garbage.txt"] = b"x" # ignoré
|
|
result = service.list_backups(cfg, storage)
|
|
dates = [ts for _, ts in result]
|
|
assert dates == sorted(dates)
|
|
assert len(result) == 2
|
|
|
|
|
|
def test_restore_replaces_db_and_keeps_safety_copy(tmp_path):
|
|
db = tmp_path / "users.sqlite"
|
|
_make_db(db, "original")
|
|
cfg = _config(db)
|
|
storage = FakeStorage()
|
|
key = service.run_backup(cfg, storage, NOW) # sauvegarde "original"
|
|
|
|
# la base change ensuite
|
|
db.unlink()
|
|
_make_db(db, "modifie")
|
|
|
|
backup_copy = service.restore(cfg, storage, key, NOW)
|
|
|
|
conn = sqlite3.connect(str(db))
|
|
val = conn.execute("SELECT val FROM t").fetchone()[0]
|
|
conn.close()
|
|
assert val == "original"
|
|
assert backup_copy.exists() # copie de secours de la base "modifie"
|
|
|
|
|
|
def test_restore_rejects_corrupt_backup(tmp_path):
|
|
db = tmp_path / "users.sqlite"
|
|
_make_db(db, "original")
|
|
cfg = _config(db)
|
|
storage = FakeStorage()
|
|
from src.backup import crypto
|
|
bad_key = make_key(cfg.prefix, NOW)
|
|
import gzip
|
|
storage.objects[bad_key] = crypto.encrypt(gzip.compress(b"not sqlite"), cfg.encryption_key)
|
|
with pytest.raises(ValueError):
|
|
service.restore(cfg, storage, bad_key, NOW)
|
|
```
|
|
|
|
- [ ] **Step 2: Lancer le test (échec attendu)**
|
|
|
|
Run: `pytest tests/backup/test_service.py -v`
|
|
Expected: FAIL — `ModuleNotFoundError: No module named 'src.backup.service'`
|
|
|
|
- [ ] **Step 3: Implémenter**
|
|
|
|
Créer `src/backup/service.py` :
|
|
|
|
```python
|
|
import os
|
|
import tempfile
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from src.backup import crypto, naming, snapshot
|
|
from src.backup.config import BackupConfig
|
|
from src.backup.rotation import select_retained
|
|
from src.backup.storage import Storage
|
|
|
|
|
|
def _dated_keys(storage: Storage, prefix: str) -> list[tuple[str, datetime]]:
|
|
dated: list[tuple[str, datetime]] = []
|
|
for key in storage.list_keys(prefix):
|
|
try:
|
|
dated.append((key, naming.parse_timestamp(key)))
|
|
except ValueError:
|
|
continue
|
|
return dated
|
|
|
|
|
|
def run_backup(config: BackupConfig, storage: Storage, now: datetime) -> str:
|
|
data = snapshot.make_snapshot(config.db_path)
|
|
encrypted = crypto.encrypt(data, config.encryption_key)
|
|
key = naming.make_key(config.prefix, now)
|
|
storage.upload_bytes(key, encrypted)
|
|
|
|
dated = _dated_keys(storage, config.prefix)
|
|
retained = select_retained((ts for _, ts in dated), now)
|
|
for k, ts in dated:
|
|
if ts not in retained:
|
|
storage.delete(k)
|
|
return key
|
|
|
|
|
|
def list_backups(config: BackupConfig, storage: Storage) -> list[tuple[str, datetime]]:
|
|
dated = _dated_keys(storage, config.prefix)
|
|
dated.sort(key=lambda item: item[1])
|
|
return dated
|
|
|
|
|
|
def restore(config: BackupConfig, storage: Storage, key: str, now: datetime) -> Path:
|
|
encrypted = storage.download_bytes(key)
|
|
data = crypto.decrypt(encrypted, config.encryption_key)
|
|
|
|
with tempfile.NamedTemporaryFile(suffix=".sqlite", delete=False) as tmp:
|
|
tmp_path = Path(tmp.name)
|
|
snapshot.write_snapshot(data, tmp_path)
|
|
if not snapshot.verify_integrity(tmp_path):
|
|
tmp_path.unlink(missing_ok=True)
|
|
raise ValueError(f"Sauvegarde corrompue, restauration annulée : {key}")
|
|
|
|
db_path = config.db_path
|
|
stamp = now.strftime("%Y%m%dT%H%M%SZ")
|
|
backup_copy = db_path.with_name(f"{db_path.name}.bak-{stamp}")
|
|
if db_path.exists():
|
|
backup_copy.write_bytes(db_path.read_bytes())
|
|
os.replace(tmp_path, db_path)
|
|
return backup_copy
|
|
```
|
|
|
|
- [ ] **Step 4: Lancer le test (succès attendu)**
|
|
|
|
Run: `pytest tests/backup/test_service.py -v`
|
|
Expected: PASS (5 tests)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/backup/service.py tests/backup/test_service.py
|
|
git commit -m "feat(backup): orchestration backup/list/restore"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: CLI
|
|
|
|
**Files:**
|
|
|
|
- Create: `src/backup/cli.py`
|
|
- Create: `src/backup/__main__.py`
|
|
- Test: `tests/backup/test_cli.py`
|
|
|
|
**Interfaces:**
|
|
|
|
- Consumes: `load_config` (T1), `service` (T7), `S3Storage` (T6).
|
|
- Produces:
|
|
|
|
```python
|
|
def main(argv=None, env=None, storage=None) -> int # backup | list | restore <key>
|
|
```
|
|
|
|
Si `storage is None`, construit `S3Storage.from_config(config)` (injectable pour les tests).
|
|
|
|
- [ ] **Step 1: Écrire le test qui échoue**
|
|
|
|
Créer `tests/backup/test_cli.py` :
|
|
|
|
```python
|
|
import sqlite3
|
|
from pathlib import Path
|
|
|
|
from cryptography.fernet import Fernet
|
|
|
|
from src.backup import cli
|
|
from tests.backup.fakes import FakeStorage
|
|
|
|
|
|
def _env(tmp_path: Path) -> dict:
|
|
db = tmp_path / "users.sqlite"
|
|
conn = sqlite3.connect(str(db))
|
|
conn.execute("CREATE TABLE t (val TEXT)")
|
|
conn.execute("INSERT INTO t VALUES ('v1')")
|
|
conn.commit()
|
|
conn.close()
|
|
return {
|
|
"USERS_DB_PATH": str(db),
|
|
"S3_BUCKET": "b",
|
|
"S3_BACKUP_PREFIX": "backups",
|
|
"S3_ENDPOINT_URL": "",
|
|
"S3_ACCESS_KEY_ID": "AK",
|
|
"S3_SECRET_ACCESS_KEY": "SK",
|
|
"BACKUP_ENCRYPTION_KEY": Fernet.generate_key().decode(),
|
|
}
|
|
|
|
|
|
def test_backup_then_list(tmp_path, capsys):
|
|
env = _env(tmp_path)
|
|
storage = FakeStorage()
|
|
assert cli.main(["backup"], env=env, storage=storage) == 0
|
|
assert cli.main(["list"], env=env, storage=storage) == 0
|
|
out = capsys.readouterr().out
|
|
assert "users-" in out
|
|
assert len(storage.objects) == 1
|
|
```
|
|
|
|
- [ ] **Step 2: Lancer le test (échec attendu)**
|
|
|
|
Run: `pytest tests/backup/test_cli.py -v`
|
|
Expected: FAIL — `ModuleNotFoundError: No module named 'src.backup.cli'`
|
|
|
|
- [ ] **Step 3: Implémenter**
|
|
|
|
Créer `src/backup/cli.py` :
|
|
|
|
```python
|
|
import argparse
|
|
import os
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
|
|
from src.backup import service
|
|
from src.backup.config import load_config
|
|
from src.backup.storage import S3Storage
|
|
|
|
|
|
def main(argv=None, env=None, storage=None) -> int:
|
|
env = env if env is not None else os.environ
|
|
parser = argparse.ArgumentParser(prog="python -m src.backup")
|
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
sub.add_parser("backup", help="Créer une sauvegarde et appliquer la rotation")
|
|
sub.add_parser("list", help="Lister les sauvegardes disponibles")
|
|
p_restore = sub.add_parser("restore", help="Restaurer une sauvegarde")
|
|
p_restore.add_argument("key", help="Clé S3 de la sauvegarde à restaurer")
|
|
args = parser.parse_args(argv)
|
|
|
|
config = load_config(env)
|
|
if storage is None:
|
|
storage = S3Storage.from_config(config)
|
|
now = datetime.now(timezone.utc)
|
|
|
|
if args.cmd == "backup":
|
|
key = service.run_backup(config, storage, now)
|
|
print(f"sauvegarde créée : {key}")
|
|
return 0
|
|
|
|
if args.cmd == "list":
|
|
backups = service.list_backups(config, storage)
|
|
if not backups:
|
|
print("(aucune sauvegarde)")
|
|
return 0
|
|
for key, ts in backups:
|
|
print(f"{ts.isoformat()} {key}")
|
|
return 0
|
|
|
|
if args.cmd == "restore":
|
|
print("⚠ Arrêtez d'abord le service : systemctl stop decpinfo")
|
|
backup_copy = service.restore(config, storage, args.key, now)
|
|
print(f"restauré depuis : {args.key}")
|
|
print(f"copie de secours de l'ancienne base : {backup_copy}")
|
|
print("Redémarrez ensuite le service : systemctl start decpinfo")
|
|
return 0
|
|
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover
|
|
sys.exit(main())
|
|
```
|
|
|
|
Créer `src/backup/__main__.py` :
|
|
|
|
```python
|
|
import sys
|
|
|
|
from src.backup.cli import main
|
|
|
|
sys.exit(main())
|
|
```
|
|
|
|
- [ ] **Step 4: Lancer le test (succès attendu)**
|
|
|
|
Run: `pytest tests/backup/test_cli.py -v`
|
|
Expected: PASS (1 test)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/backup/cli.py src/backup/__main__.py tests/backup/test_cli.py
|
|
git commit -m "feat(backup): CLI backup/list/restore"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Déploiement (systemd, env, doc)
|
|
|
|
**Files:**
|
|
|
|
- Create: `deploy/decpinfo-backup.service`
|
|
- Create: `deploy/decpinfo-backup.timer`
|
|
- Modify: `.template.env`
|
|
- Modify: `CLAUDE.md` (section déploiement — commandes d'installation du timer)
|
|
|
|
**Interfaces:**
|
|
|
|
- Consumes: `python -m src.backup backup` (T8).
|
|
- Produces: artefacts de déploiement (pas de test automatisé ; vérification par validation systemd).
|
|
|
|
- [ ] **Step 1: Variables d'environnement**
|
|
|
|
Ajouter à la fin de `.template.env` :
|
|
|
|
```bash
|
|
# Sauvegarde de users.sqlite vers un stockage S3-compatible
|
|
S3_ENDPOINT_URL=https://s3.exemple.net
|
|
S3_BUCKET=decp-backups
|
|
S3_BACKUP_PREFIX=backups
|
|
S3_ACCESS_KEY_ID=
|
|
S3_SECRET_ACCESS_KEY=
|
|
# Clé Fernet : générer avec
|
|
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
|
# À CONSERVER HORS DU SERVEUR : sans elle, les sauvegardes sont irrécupérables.
|
|
BACKUP_ENCRYPTION_KEY=
|
|
```
|
|
|
|
- [ ] **Step 2: Unité service systemd**
|
|
|
|
Créer `deploy/decpinfo-backup.service` :
|
|
|
|
```ini
|
|
[Unit]
|
|
Description=Sauvegarde horaire de users.sqlite (decp.info) vers S3
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
User=decpinfo
|
|
WorkingDirectory=/var/www/decpinfo
|
|
EnvironmentFile=/var/www/decpinfo/.env
|
|
ExecStart=/var/www/decpinfo/.venv/bin/python -m src.backup backup
|
|
```
|
|
|
|
> Adapter `decpinfo` à la valeur réelle de `vars.APP_NAME` si différente, et vérifier que `.env` ne contient que des lignes `CLE=valeur` simples (systemd `EnvironmentFile` ne gère pas `export` ni les variables interpolées).
|
|
|
|
- [ ] **Step 3: Unité timer systemd**
|
|
|
|
Créer `deploy/decpinfo-backup.timer` :
|
|
|
|
```ini
|
|
[Unit]
|
|
Description=Déclenche la sauvegarde horaire de users.sqlite (decp.info)
|
|
|
|
[Timer]
|
|
OnCalendar=hourly
|
|
Persistent=true
|
|
|
|
[Install]
|
|
WantedBy=timers.target
|
|
```
|
|
|
|
- [ ] **Step 4: Documenter l'installation**
|
|
|
|
Dans `CLAUDE.md`, sous `### Deployment`, ajouter :
|
|
|
|
```markdown
|
|
#### Sauvegarde de la base utilisateurs
|
|
|
|
`users.sqlite` est sauvegardée toutes les heures sur S3 par un timer systemd
|
|
(voir `deploy/decpinfo-backup.{service,timer}`). Installation initiale (une fois,
|
|
sur le serveur, en root) :
|
|
|
|
\`\`\`bash
|
|
cp deploy/decpinfo-backup.service deploy/decpinfo-backup.timer /etc/systemd/system/
|
|
systemctl daemon-reload
|
|
systemctl enable --now decpinfo-backup.timer
|
|
systemctl list-timers decpinfo-backup.timer # vérifier le prochain déclenchement
|
|
\`\`\`
|
|
|
|
Restauration manuelle :
|
|
|
|
\`\`\`bash
|
|
cd /var/www/decpinfo && source .venv/bin/activate
|
|
python -m src.backup list
|
|
systemctl stop decpinfo
|
|
python -m src.backup restore backups/users-YYYYMMDDTHHMMSSZ.sqlite.gz.enc
|
|
systemctl start decpinfo
|
|
\`\`\`
|
|
```
|
|
|
|
(Retirer les barres obliques inverses `\` devant les triples accents — elles ne servent qu'à l'échappement dans ce plan.)
|
|
|
|
- [ ] **Step 5: Vérifier la suite complète**
|
|
|
|
Run: `pytest tests/backup/ -v`
|
|
Expected: PASS (tous les tests des tâches 1-8)
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add deploy/decpinfo-backup.service deploy/decpinfo-backup.timer .template.env CLAUDE.md
|
|
git commit -m "feat(backup): unités systemd, variables d'env et doc de déploiement"
|
|
```
|
|
|
|
---
|
|
|
|
## Auto-revue du plan
|
|
|
|
- **Couverture de la spec :** déclencheur systemd (T9) ✓ ; chiffrement avant upload (T4, intégré T7) ✓ ; snapshot cohérent WAL-safe (T5) ✓ ; 4 paliers de rétention en union (T3) ✓ ; nommage horodaté trié (T2) ✓ ; stockage S3-compatible via endpoint_url (T6) ✓ ; restauration manuelle avec contrôle d'intégrité + copie de secours + remplacement atomique (T7, CLI T8) ✓ ; variables d'env + gestion de la clé (T9) ✓ ; tests unitaires (rotation, crypto, naming) + intégration (snapshot, S3 moto, service, cli) ✓.
|
|
- **Placeholders :** aucun « TODO/TBD » ; tout le code et toutes les commandes sont explicites.
|
|
- **Cohérence des types :** `make_key(prefix, ts)`/`parse_timestamp(key)`, `select_retained(timestamps, now) -> set`, `encrypt/decrypt(bytes, str) -> bytes`, `make_snapshot(Path) -> bytes` / `write_snapshot(bytes, Path)` / `verify_integrity(Path) -> bool`, interface `Storage` (upload_bytes/download_bytes/list_keys/delete), `run_backup/list_backups/restore` — signatures identiques entre définition (T1-T7) et usage (T7-T8).
|