Files
adam-acs/docs/migration/tr069-exchange-viewer.md
T
Kevin L'Hereec 0c83a4ccb2 feat: TR-069 Exchange Viewer - structured MongoDB storage with SSE streaming and sequence diagram UI
- MongoDB storage: cwmp_exchanges collection with indexes (deviceId+timestamp, deviceId+sequenceNumber unique, TTL 24h)
- ExchangeStoreService: CWMP method extraction, per-device queue for ordering, retry logic, message cap (5000/device)
- Exchange API: GET/DELETE/SSE stream endpoints with Last-Event-ID reconnection
- CwmpLoggerMiddleware: dual-write (MongoDB + legacy files) with fire-and-forget
- Frontend: SequenceViewer (CPE↔ACS lifelines, arrows, auto-scroll, virtual scroll) + InspectorPanel (formatted XML, copy, syntax highlighting)
- Tr069Trace page: integrated new viewer with col-6/col-6 layout
- DeviceDetail page: collapsible exchange section when device is traced
- 14 property-based tests, 40+ unit tests, 5 integration tests
- Health check includes index verification
- Config: feature flags, SSE timeouts, retry policy
2026-06-03 15:52:44 +02:00

593 lines
18 KiB
Markdown

# Guide de migration : TR-069 Exchange Viewer
## Résumé
Ce guide décrit la migration du système de trace TR-069 depuis le stockage fichier (logs `.log` rotatifs) vers le stockage structuré MongoDB (`cwmp_exchanges`). La migration suit une approche en 3 phases pour garantir zéro perte de données et un rollback possible à chaque étape.
| Phase | Durée | Description | Risque |
|-------|-------|-------------|--------|
| Phase 1 — Dual-Write | Semaines 1-2 | Écriture simultanée fichiers + MongoDB | Faible |
| Phase 2 — Read from MongoDB | Semaines 3-4 | API lit depuis MongoDB, fichiers en backup | Moyen |
| Phase 3 — Deprecate Files | Semaine 5+ | Suppression du système fichier | Irréversible |
---
## Prérequis
### Infrastructure
- MongoDB 5.0+ (pas besoin de replica set — le système utilise un EventEmitter in-process, pas de change streams)
- Espace disque MongoDB : prévoir ~500 Mo pour 10 000 devices actifs avec 5 000 messages max chacun
- L'application adam-acs doit être redémarrée après la migration de base
### Variables d'environnement
```bash
# Feature flags (déjà présents dans config/exchange_store.ts)
FEATURE_EXCHANGE_PERSISTENCE=true # Active les écritures MongoDB
FEATURE_SSE_ENABLED=true # Active le streaming SSE
FEATURE_EXCHANGE_API_SOURCE=mongodb # Source des données API (mongodb | file)
# Configuration optionnelle
EXCHANGE_STORE_MAX_MESSAGES=5000 # Cap messages par device
EXCHANGE_STORE_TTL_SECONDS=86400 # TTL 24h
EXCHANGE_STORE_READ_TIMEOUT_MS=5000 # Timeout requêtes API
```
---
## Phase 1 : Dual-Write (Semaines 1-2)
### Objectif
Écrire simultanément dans les deux systèmes (fichiers ET MongoDB) pour valider la fiabilité du nouveau système sans risque de perte.
### 1.1 Exécuter la migration MongoDB
La migration crée la collection `cwmp_exchanges` et ses index :
```bash
# Exécuter la migration
node ace migration:run --connection=mongodb
```
Cela exécute `database/mongo_migrations/011_create_cwmp_exchanges_collection.ts` qui crée :
- Collection `cwmp_exchanges` avec 3 index :
- `{ deviceId: 1, timestamp: 1 }` — requêtes par device
- `{ deviceId: 1, sequenceNumber: 1 }` (unique) — numérotation séquentielle et reconnexion SSE
- `{ createdAt: 1 }` (TTL: 86400s) — nettoyage automatique après 24h
- Collection `cwmp_exchange_counters` — compteurs atomiques de séquence
### 1.2 Vérifier les index
```bash
# Via mongosh
mongosh --eval "
db.cwmp_exchanges.getIndexes().forEach(idx => {
print(idx.name + ': ' + JSON.stringify(idx.key))
})
"
```
Résultat attendu :
```
_id_: {"_id":1}
idx_deviceId_timestamp: {"deviceId":1,"timestamp":1}
idx_deviceId_sequenceNumber: {"deviceId":1,"sequenceNumber":1}
idx_createdAt_ttl: {"createdAt":1}
```
### 1.3 Activer le dual-write
Le dual-write est déjà implémenté dans `CwmpLoggerMiddleware`. Vérifier les feature flags :
```bash
# .env — Phase 1
FEATURE_EXCHANGE_PERSISTENCE=true
FEATURE_SSE_ENABLED=true
FEATURE_EXCHANGE_API_SOURCE=file # GARDER 'file' en Phase 1
```
Le middleware écrit dans les deux systèmes :
```typescript
// app/middleware/cwmp_logger_middleware.ts (extrait)
if (isTraced && deviceId) {
// Nouveau système : MongoDB (fire-and-forget)
exchangeStore.persistExchange({ deviceId, direction, sessionId, rawXml })
.catch(() => {})
// Ancien système : fichiers (gardé pour rollback)
traceService.writeExchange(deviceId, direction, sessionId, prettyXml)
}
```
### 1.4 Redémarrer l'application
```bash
docker compose build app
docker compose up -d app
```
### 1.5 Vérifier le fonctionnement
Activer le trace sur un device et déclencher un Inform :
```bash
# Vérifier que MongoDB reçoit les données
mongosh --eval "db.cwmp_exchanges.countDocuments({})"
# Vérifier les derniers échanges
mongosh --eval "
db.cwmp_exchanges.find({}).sort({timestamp: -1}).limit(3).forEach(doc => {
print(doc.deviceId + ' | ' + doc.direction + ' | ' + doc.cwmpMethod)
})
"
```
### 1.6 Vérification de cohérence (Dual-Write)
Pendant la Phase 1, vérifier régulièrement que les deux systèmes restent synchronisés :
#### Script de comparaison
```bash
#!/bin/bash
# scripts/verify_dual_write.sh
# Compare le nombre d'échanges entre fichiers et MongoDB pour chaque device tracé
DEVICE_ID=$1
if [ -z "$DEVICE_ID" ]; then
echo "Usage: $0 <deviceId>"
exit 1
fi
# Compteur fichier (nombre de séparateurs '═' = nombre d'échanges)
FILE_COUNT=$(grep -c '═════' "logs/tr069/${DEVICE_ID}.log" 2>/dev/null || echo "0")
# Compteur MongoDB
MONGO_COUNT=$(mongosh --quiet --eval "db.cwmp_exchanges.countDocuments({deviceId: '${DEVICE_ID}'})")
echo "Device: ${DEVICE_ID}"
echo " Fichiers : ${FILE_COUNT} échanges"
echo " MongoDB : ${MONGO_COUNT} échanges"
if [ "$FILE_COUNT" != "$MONGO_COUNT" ]; then
DIFF=$((FILE_COUNT - MONGO_COUNT))
echo " ⚠️ Écart : ${DIFF} messages"
if [ "${DIFF#-}" -gt $((FILE_COUNT / 20)) ]; then
echo " 🚨 Écart > 5% — Investigation requise !"
fi
else
echo " ✅ Cohérent"
fi
```
#### Critères d'acceptation Phase 1
| Critère | Seuil | Action si dépassé |
|---------|-------|-------------------|
| Écart fichier/MongoDB | < 5% | Vérifier les logs d'erreur ExchangeStore |
| Erreurs de persistance | < 0.1% | Vérifier la connectivité MongoDB |
| Latence MongoDB p95 | < 100ms | Vérifier la charge et les index |
| Messages perdus (critiques) | 0 après retries | Vérifier les logs de retry exhaustion |
#### Durée recommandée
Maintenir le dual-write pendant **minimum 1 semaine** avec des vérifications quotidiennes. Passer en Phase 2 uniquement si les critères ci-dessus sont respectés de manière constante.
---
## Phase 2 : Read from MongoDB (Semaines 3-4)
### Objectif
Basculer la lecture des données vers MongoDB tout en gardant les fichiers en écriture backup.
### 2.1 Changer la source de lecture API
```bash
# .env — Phase 2
FEATURE_EXCHANGE_PERSISTENCE=true
FEATURE_SSE_ENABLED=true
FEATURE_EXCHANGE_API_SOURCE=mongodb # ← Changer de 'file' à 'mongodb'
```
### 2.2 Redémarrer l'application
```bash
docker compose restart app
```
### 2.3 Vérifier le fonctionnement
```bash
# Vérifier que l'API retourne des données depuis MongoDB
curl -s "http://localhost:8080/api/v1/tr069-trace/<DEVICE_ID>/exchanges?limit=5" | \
python3 -c "
import sys, json
data = json.load(sys.stdin)
print(f\"Source: MongoDB\")
print(f\"Exchanges: {len(data.get('exchanges', []))} messages\")
print(f\"Metadata: {data.get('metadata', {})}\")
"
```
### 2.4 Vérifier le SSE
```bash
# Ouvrir une connexion SSE (ctrl+C pour arrêter)
curl -N -H "Accept: text/event-stream" \
"http://localhost:8080/api/v1/tr069-trace/<DEVICE_ID>/exchanges/stream"
```
Déclencher un Inform sur le device tracé et vérifier qu'un événement SSE arrive en temps réel.
### 2.5 Vérifier le fallback
Si MongoDB est indisponible, le frontend doit retomber sur les fichiers :
```typescript
// Logique dans DeviceDetail.vue
const exchanges = await api.getExchanges(deviceId)
.catch(() => legacyService.readExchanges(deviceId))
```
### 2.6 Critères de passage Phase 2 → Phase 3
| Critère | Seuil | Vérifié |
|---------|-------|---------|
| API MongoDB fonctionne sans erreur | 48h continues | ☐ |
| SSE pousse les événements en < 500ms | p95 vérifié | ☐ |
| Aucune perte de message critique | 0 sur 48h | ☐ |
| Reconnexion SSE (Last-Event-ID) valide | Testé manuellement | ☐ |
| Purge (DELETE) fonctionne | 204 retourné | ☐ |
---
## Phase 3 : Deprecate Old System (Semaine 5+)
### Objectif
Supprimer le système fichier une fois que MongoDB est validé en production.
### 3.1 Supprimer le dual-write du middleware
Dans `app/middleware/cwmp_logger_middleware.ts`, retirer les appels au système fichier :
```typescript
// AVANT (dual-write)
if (isTraced && deviceId) {
exchangeStore.persistExchange({ deviceId, direction, sessionId, rawXml }).catch(() => {})
traceService.writeExchange(deviceId, direction, sessionId, prettyXml) // ← SUPPRIMER
}
// APRÈS (MongoDB uniquement)
if (isTraced && deviceId) {
exchangeStore.persistExchange({ deviceId, direction, sessionId, rawXml }).catch(() => {})
}
```
### 3.2 Supprimer les dépendances obsolètes
1. Retirer l'import `xml-formatter` du middleware (le formatage n'est plus nécessaire côté persistance)
2. Supprimer les appels à `Tr069TraceService.writeExchange()` dans le middleware
3. **NE PAS** supprimer `Tr069TraceService` lui-même — il est encore utilisé pour gérer l'état trace (start/stop)
### 3.3 Nettoyer les fichiers de logs
```bash
# Archiver les anciens logs avant suppression
tar -czf logs/tr069-archive-$(date +%Y%m%d).tar.gz logs/tr069/
rm -rf logs/tr069/*.log*
```
### 3.4 Mise à jour de la configuration
```bash
# .env — Phase 3 (état final)
FEATURE_EXCHANGE_PERSISTENCE=true
FEATURE_SSE_ENABLED=true
FEATURE_EXCHANGE_API_SOURCE=mongodb
```
### 3.5 Vérification finale
```bash
# Vérifier qu'aucun fichier .log n'est créé
ls -la logs/tr069/
# Vérifier que le système fonctionne uniquement via MongoDB
curl -s "http://localhost:8080/api/v1/health/tr069-exchanges" | python3 -m json.tool
```
---
## Procédures de Rollback
### Rollback Phase 1 → Système fichier seul
**Situation :** MongoDB pose problème, il faut revenir au système fichier uniquement.
```bash
# 1. Désactiver la persistance MongoDB
FEATURE_EXCHANGE_PERSISTENCE=false
# 2. Redémarrer
docker compose restart app
# 3. Vérifier que les fichiers continuent d'être écrits
ls -la logs/tr069/
```
**Impact :** Les données déjà en MongoDB restent accessibles mais ne sont plus alimentées. Le SSE ne fonctionne plus. L'UI retombe sur les fichiers.
**Temps de rollback :** < 2 minutes (changement d'env + restart)
### Rollback Phase 2 → Phase 1 (Dual-Write)
**Situation :** L'API MongoDB a des problèmes, revenir au dual-write avec lecture fichier.
```bash
# 1. Changer la source API
FEATURE_EXCHANGE_API_SOURCE=file
# 2. Redémarrer
docker compose restart app
```
**Impact :** L'API lit les fichiers au lieu de MongoDB. Le dual-write continue. Aucune perte de données.
**Temps de rollback :** < 2 minutes
### Rollback Phase 3 → Phase 2
**Situation :** Après suppression du dual-write, MongoDB pose problème.
**⚠️ ATTENTION : Ce rollback nécessite un redéploiement du code.**
1. Revert du commit qui a supprimé le dual-write dans `CwmpLoggerMiddleware`
2. Redéployer l'application
3. Vérifier que les fichiers sont à nouveau écrits
```bash
# Identifier le commit de suppression du dual-write
git log --oneline -- app/middleware/cwmp_logger_middleware.ts
# Revert
git revert <commit-hash>
docker compose build app
docker compose up -d app
```
**Impact :** Nécessite un redéploiement (~5 minutes). Les échanges entre la suppression et le rollback ne sont PAS dans les fichiers.
**Temps de rollback :** 5-10 minutes
### Rollback SSE uniquement
**Situation :** Le SSE cause des problèmes (connexions, mémoire) mais MongoDB est stable.
```bash
# Désactiver uniquement le SSE
FEATURE_SSE_ENABLED=false
# Redémarrer
docker compose restart app
```
**Impact :** Le frontend bascule automatiquement en REST polling (toutes les 5 secondes). Les données MongoDB sont toujours disponibles. Pas de perte.
### Rollback complet (retour système fichier)
**Situation :** Problème majeur, tout désactiver.
```bash
# Tout désactiver
FEATURE_EXCHANGE_PERSISTENCE=false
FEATURE_SSE_ENABLED=false
FEATURE_EXCHANGE_API_SOURCE=file
# Redémarrer
docker compose restart app
```
**Impact :** Retour complet au système fichier. MongoDB n'est plus utilisé. L'UI affiche les logs comme avant la migration.
---
## Migration des données historiques (optionnel)
Si vous souhaitez importer les logs fichier existants dans MongoDB, utilisez le script ci-dessous. Cette étape est **optionnelle** car les anciens logs disparaîtront naturellement via le TTL 24h.
### Script d'import
```typescript
// scripts/migrate_file_logs_to_mongodb.ts
import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import { getMongoDb } from '#providers/mongodb_provider'
const LOG_DIR = join(process.cwd(), 'logs', 'tr069')
const SEPARATOR = '═'.repeat(80)
interface ParsedEntry {
deviceId: string
timestamp: string
direction: 'CPE→ACS' | 'ACS→CPE'
sessionId: string
rawXml: string
}
function parseLogFile(deviceId: string, content: string): ParsedEntry[] {
const entries: ParsedEntry[] = []
const blocks = content.split(SEPARATOR).filter((b) => b.trim())
for (let i = 0; i < blocks.length - 1; i += 2) {
const header = blocks[i].trim()
const xml = blocks[i + 1]?.trim() || ''
// Parse header: [2025-01-15T10:30:00.123Z] CPE→ACS | 10.0.0.1:54321->192.168.1.1:8080
const headerMatch = header.match(
/\[(.+?)\]\s+(CPE→ACS|ACS→CPE)\s+\|\s+(.+)/
)
if (!headerMatch) continue
entries.push({
deviceId,
timestamp: headerMatch[1],
direction: headerMatch[2] as 'CPE→ACS' | 'ACS→CPE',
sessionId: headerMatch[3],
rawXml: xml,
})
}
return entries
}
async function migrateAll(): Promise<void> {
const db = getMongoDb()
const collection = db.collection('cwmp_exchanges')
const counters = db.collection('cwmp_exchange_counters')
const files = readdirSync(LOG_DIR).filter((f) => f.endsWith('.log'))
let totalMigrated = 0
for (const file of files) {
const deviceId = file.replace('.log', '')
const content = readFileSync(join(LOG_DIR, file), 'utf-8')
const entries = parseLogFile(deviceId, content)
if (entries.length === 0) continue
// Get or initialize sequence counter
const counterResult = await counters.findOneAndUpdate(
{ _id: deviceId as any },
{ $inc: { seq: entries.length } },
{ upsert: true, returnDocument: 'after' }
)
const startSeq = (counterResult!.seq as number) - entries.length + 1
// Build documents
const docs = entries.map((entry, idx) => ({
...entry,
sequenceNumber: startSeq + idx,
cwmpMethod: extractMethod(entry.rawXml),
createdAt: new Date(entry.timestamp),
}))
// Bulk insert
await collection.insertMany(docs, { ordered: false }).catch((err) => {
console.warn(` ⚠️ Duplicates ignored for ${deviceId}: ${err.message}`)
})
totalMigrated += docs.length
console.log(` ✅ ${deviceId}: ${docs.length} échanges migrés`)
}
console.log(`\n📊 Total: ${totalMigrated} échanges migrés depuis ${files.length} fichiers`)
}
function extractMethod(xml: string): string {
if (!xml || xml.trim() === '') return 'Empty'
const match = xml.match(/<cwmp:(\w+)[\s/>]/)
return match ? match[1] : 'Unknown'
}
migrateAll().catch(console.error)
```
### Exécution
```bash
# Depuis la racine du projet
npx ts-node scripts/migrate_file_logs_to_mongodb.ts
```
**Notes :**
- Le script est idempotent : les doublons sont ignorés (grâce à l'index unique deviceId + sequenceNumber)
- Les documents importés auront un `createdAt` basé sur le timestamp du log, donc ils seront supprimés par le TTL 24h si ils datent de plus de 24 heures
- Pour conserver les données historiques plus longtemps, ajustez temporairement `EXCHANGE_STORE_TTL_SECONDS`
---
## Vérifications post-migration
### Checklist
| # | Vérification | Commande | Attendu |
|---|-------------|----------|---------|
| 1 | Collection existe | `db.cwmp_exchanges.stats()` | Collection présente |
| 2 | Index créés | `db.cwmp_exchanges.getIndexes()` | 4 index (dont _id) |
| 3 | TTL actif | `db.cwmp_exchanges.getIndexes()` | `expireAfterSeconds: 86400` |
| 4 | Compteurs fonctionnent | `db.cwmp_exchange_counters.find()` | Entrées par device |
| 5 | API accessible | `GET /api/v1/tr069-trace/:id/exchanges` | 200 avec données |
| 6 | SSE connecte | `GET /api/v1/tr069-trace/:id/exchanges/stream` | Event stream ouvert |
| 7 | Health check | `GET /api/v1/health/tr069-exchanges` | `status: "healthy"` |
### Commandes de diagnostic
```bash
# Statistiques collection
mongosh --eval "
const stats = db.cwmp_exchanges.stats()
print('Documents:', stats.count)
print('Taille:', (stats.size / 1024 / 1024).toFixed(2), 'Mo')
print('Index size:', (stats.totalIndexSize / 1024 / 1024).toFixed(2), 'Mo')
"
# Vérifier le TTL monitor
mongosh --eval "
db.cwmp_exchanges.aggregate([
{ \$group: { _id: null, oldest: { \$min: '\$createdAt' }, newest: { \$max: '\$createdAt' } } }
]).forEach(doc => {
print('Plus ancien:', doc.oldest)
print('Plus récent:', doc.newest)
const ageH = (Date.now() - new Date(doc.oldest).getTime()) / 3600000
print('Âge max:', ageH.toFixed(1), 'heures (devrait être < 24h)')
})
"
# Vérifier les compteurs de séquence
mongosh --eval "
db.cwmp_exchange_counters.find().sort({seq: -1}).limit(5).forEach(doc => {
print(doc._id + ': seq=' + doc.seq)
})
"
```
---
## Résumé des feature flags
| Flag | Phase 1 | Phase 2 | Phase 3 | Rollback |
|------|---------|---------|---------|----------|
| `FEATURE_EXCHANGE_PERSISTENCE` | `true` | `true` | `true` | `false` |
| `FEATURE_SSE_ENABLED` | `true` | `true` | `true` | `false` |
| `FEATURE_EXCHANGE_API_SOURCE` | `file` | `mongodb` | `mongodb` | `file` |
---
## FAQ
### Que se passe-t-il si MongoDB tombe pendant le dual-write ?
Les écritures MongoDB échouent silencieusement (fire-and-forget). Le système fichier continue normalement. Les messages critiques sont retentés 3 fois avec backoff exponentiel (1s, 2s, 4s) avant d'être abandonnés. La session CWMP n'est jamais interrompue.
### Le TTL va-t-il supprimer des données pendant la migration ?
Oui. Le TTL de 24h s'applique dès la création de l'index. Si vous importez des logs historiques de plus de 24h, ils seront supprimés par le TTL monitor MongoDB (qui tourne ~toutes les 60 secondes). Augmentez temporairement `EXCHANGE_STORE_TTL_SECONDS` si nécessaire.
### Puis-je migrer les données fichier vers MongoDB ?
Oui, voir la section "Migration des données historiques". C'est optionnel car le système fonctionne immédiatement avec les nouveaux échanges. Les anciens logs fichier restent lisibles via `FEATURE_EXCHANGE_API_SOURCE=file`.
### Combien d'espace MongoDB est nécessaire ?
Estimation : ~1 Ko par échange (métadonnées) + taille du XML brut (typiquement 2-50 Ko). Avec le cap de 5 000 messages par device et le TTL 24h, la croissance est bornée. Pour 100 devices tracés simultanément : ~500 Mo maximum.
### Le dual-write a-t-il un impact sur les performances CWMP ?
Non. Les écritures MongoDB sont fire-and-forget (`catch(() => {})`) et ne bloquent jamais le traitement CWMP. En cas de lenteur MongoDB, le middleware continue sans attendre.