video-surveillance-app/README.md

320 lines
14 KiB
Markdown
Raw Normal View History

# iot-alarm-system
Système d'alarme événementiel pour capteurs IoT.
**Java 21 · Spring Boot 3 · Apache Kafka · Architecture hexagonale · CQRS · DDD**
🔗 **Démonstration en ligne : https://video-seurveillance.belkacemi.me**
---
## Le contexte
Mon studio a été cambriolé.
Les offres de vidéosurveillance du marché se ressemblent toutes : une caméra à
80 €, puis un abonnement mensuel pour avoir le droit d'accéder à ses propres
images. J'avais un vieil iPhone dans un tiroir et une bibliothèque bien remplie.
L'iPhone est désormais calé entre deux livres, objectif vers la porte d'entrée.
Ce dépôt est le backend qui écoute ce capteur : il ingère les détections de
mouvement, décide lesquelles constituent une intrusion, et pousse l'alerte en
temps réel sur une console de supervision.
Le problème est volontairement simple. Ce qui est travaillé ici, c'est la
**structure** : un domaine métier isolé du framework, un bus d'événements, une
séparation lecture/écriture, et des règles d'architecture vérifiées par les
tests plutôt que par la discipline.
---
## Le flux
```
┌──────────────────┐
│ iPhone 6s │ Détection de mouvement
│ "iphone-biblio" │ {"deviceId":"iphone-biblio","type":"MOTION_DETECTED"}
└────────┬─────────┘
│ POST /api/v1/sensor-events
▼
┌────────────────────────────┐
│ SensorIngestController │ ADAPTER ENTRANT
│ → 202 Accepted │ Répond avant tout traitement métier :
└────────┬───────────────────┘ le capteur n'attend pas la base.
│
▼
┌────────────────────────────┐
│ SensorEventKafkaPublisher │ ADAPTER SORTANT
└────────┬───────────────────┘ Clé de partition = deviceId → ordre garanti
│ par capteur.
▼
╔═══════════════════════╗
║ KAFKA sensor-events ║ 3 partitions · acks=all · idempotent
╚═══════════┬═══════════╝ (poison pill → sensor-events-dlq)
│
▼
┌────────────────────────────┐
│ SensorEventKafkaConsumer │ ADAPTER ENTRANT
└────────┬───────────────────┘ Désérialise, délègue, instrumente. Zéro règle.
│
▼
┌────────────────────────────────────────────────────────┐
│ AlarmSystem.evaluate() ◄── LE CŒUR DU SYSTÈME│
│ │
│ 1. alarme désarmée ? → Ignored(ALARM_DISARMED)
│ 2. heartbeat ? → Ignored(NOT_INTRUSION_RELEVANT)
│ 3. délai de sortie en cours ? → Ignored(WITHIN_EXIT_DELAY)
│ 4. confiance < seuil ? → Ignored(CONFIDENCE_TOO_LOW)
│ 5. alerte récente ? → Ignored(WITHIN_COOLDOWN)
│ │
│ sinon → TRIGGERED + Alert + IntrusionDetected │
└────────┬───────────────────────────────────────────────┘
│
┌─────┴─────────────────────────┐
▼ ▼
┌──────────────────┐ ┌──────────────────────────┐
│ PostgreSQL │ │ WebSocket STOMP │ après commit
│ (write model) │ │ /topic/alerts │ uniquement
└──────────────────┘ └────────────┬─────────────┘
▼
┌──────────────────────────┐
│ Console : fond rouge, │
│ "INTRUSION DÉTECTÉE" │
└──────────────────────────┘
```
---
## L'architecture
### Hexagonale (ports & adapters)
```
domain/ Le métier. Zéro import de framework.
model/ AlarmSystem (agrégat racine), Alert, SensorEvent,
AlarmPolicy, AlarmDecision (sealed)
event/ DomainEvent (sealed) : IntrusionDetected, AlarmArmed…
port/in/ Ports primaires — ce que le système sait faire
port/out/ Ports secondaires — ce dont il a besoin (SPI)
application/ Orchestration. Charge, appelle, persiste, notifie.
command/ Côté écriture du CQRS
query/ Côté lecture — son propre port, son propre modèle
infrastructure/ Les adapters. C'est ici, et seulement ici, qu'on trouve
les mots « Kafka », « JPA », « WebSocket ».
presentation/ REST + WebSocket. Traduit HTTP en commandes.
```
Les dépendances pointent **vers l'intérieur**. Le domaine ne connaît ni Spring,
ni Kafka, ni PostgreSQL — il déclare des interfaces (`SensorEventPublisher`,
`AlertRepository`) que l'infrastructure implémente.
Conséquence mesurable : les 13 tests de `AlarmSystemTest` couvrent l'intégralité
de la logique de détection et s'exécutent en **48 millisecondes**, sans
conteneur, sans base, sans `Thread.sleep`.
### Ces règles sont testées, pas documentées
Un schéma dans un README dérive en trois sprints. Celles-ci cassent le build :
```java
@Test
void domainHasNoFrameworkDependency() {
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAnyPackage(
"org.springframework..", "jakarta.persistence..",
"org.apache.kafka..", "io.micrometer..")
.check(classes);
}
```
7 règles ArchUnit s'exécutent à chaque `mvn test` — **et à chaque `docker build`**.
Une violation de l'architecture hexagonale ne franchit pas l'étape de packaging.
### CQRS
La séparation n'est pas cosmétique : les deux côtés ne partagent aucun modèle.
| | Écriture | Lecture |
|---|---|---|
| Entrée | `ArmAlarmCommand`, `IngestSensorEventCommand` | `AlarmQueryService` |
| Chemin | passe par l'agrégat, applique les invariants | requête directe, ne charge jamais l'agrégat |
| Modèle | `Alert` (entité, comportement) | `AlertView` (record, libellés pré-calculés) |
| Port | `domain.port.out` | `application.query.port` |
| Transaction | `@Transactional` | `@Transactional(readOnly = true)` |
Compter les alertes non acquittées se fait par un `COUNT(*)`, pas en
réhydratant un agrégat dont on n'appellerait aucune méthode. Brancher un cache
Redis ou un index Elasticsearch sur la lecture ne demanderait qu'une nouvelle
implémentation de `AlarmReadModelPort`.
### DDD
- **Agrégat racine** — `AlarmSystem` est le seul point d'entrée pour modifier
l'état. Aucun service ne décide à sa place.
- **Value Objects** — `DeviceId` valide son format à la construction ;
au-delà de la frontière, aucune `String` non validée ne circule.
- **Entité** — `Alert` a une identité (`AlertId`, générée par le domaine et non
par la base) et un cycle de vie (acquittement à usage unique).
- **Interfaces scellées** — `AlarmDecision` est `Triggered | Ignored(reason)`.
Le `switch` qui la consomme est vérifié exhaustivement par le compilateur :
ajouter un troisième cas casse la compilation au lieu de tomber dans un `else`.
- **Un rejet dit pourquoi** — `IgnoreReason` alimente à la fois les logs et un
tag Micrometer. « Pourquoi l'alarme n'a pas sonné cette nuit ? » se répond par
une requête PromQL, pas par une lecture de logs.
---
## Les décisions non évidentes
Les choix ci-dessous ne sont pas les plus courts. Ils sont justifiés ici parce
que c'est précisément ce qui distingue un projet de démonstration d'un système
qu'on accepte de laisser tourner.
**`202 Accepted` et non `201 Created`** — à cet instant, rien n'a été décidé.
L'événement est sur le bus ; le traitement suivra. Le code de statut dit la
vérité sur ce qui s'est passé.
**Notification WebSocket après commit** — pousser l'alerte dans la transaction
signifierait afficher « INTRUSION » sur une écriture qui peut encore partir en
rollback. `AfterCommit` diffère l'effet de bord jusqu'au commit effectif.
**Entité JPA ≠ entité du domaine** — annoter directement `Alert` aurait imposé
un constructeur sans argument, des setters et des champs mutables : exactement
ce que l'encapsulation cherche à empêcher. Le prix est un mapper ; le bénéfice
est un domaine testable sans base.
**`SensorEventMessage` ≠ `SensorEvent`** — le format Kafka est un contrat public
partagé avec un producteur qu'on ne maîtrise pas (l'app iPhone). Sérialiser
l'objet du domaine ferait qu'un renommage de champ casse le capteur en
production.
**Anti-rafale dans l'agrégat** — sans ce filtre, un intrus qui traverse la pièce
génère quarante alertes en dix secondes. C'est une règle métier, elle est donc
dans le domaine, pas dans le consumer.
**Délai de sortie** — la différence entre un système utilisable et un système
qu'on finit par laisser désarmé en permanence.
**`ErrorHandlingDeserializer` + DLQ** — sans lui, *un seul* message malformé met
le consumer dans une boucle de replay infinie et bloque tous les événements
suivants, y compris une vraie intrusion.
**`Clock` injectée** — `Instant.now()` en dur rendrait le délai de sortie et
l'anti-rafale intestables autrement qu'avec des `Thread.sleep`.
**KRaft, pas Zookeeper** — Zookeeper est déprécié depuis Kafka 3.5 et supprimé
en 4.0. Le conserver ajouterait un conteneur, ~400 Mo de RAM et une pièce à
maintenir, pour une fonction que le broker assure désormais lui-même.
**`ddl-auto: validate`** — le schéma appartient à Flyway. L'application refuse
de démarrer si une entité et sa table ont divergé, plutôt que de tomber en
marche sur la première requête.
---
## Démarrage
### Tout en conteneurs
```bash
grep -v '^DB_PASSWORD=' .env.example > .env
printf 'DB_PASSWORD=%s\n' "$(openssl rand -hex 24)" >> .env && chmod 600 .env
docker compose up -d --build
docker compose logs -f app
```
Console sur http://localhost:8080 — le simulateur de capteur produit du trafic
dès le démarrage.
Pour un frontal Nginx en plus (http://localhost:8081) :
`docker compose --profile standalone up -d`
### En développement
```bash
docker compose up -d kafka # le broker seul
mvn spring-boot:run -Dspring-boot.run.profiles=local # H2 en mémoire
```
### Tests
```bash
mvn test # unitaires + ArchUnit — aucune infra requise
mvn verify -Pintegration # Testcontainers (Kafka + PostgreSQL réels)
```
---
## API
| Méthode | Route | Rôle |
|---|---|---|
| `POST` | `/api/v1/sensor-events` | Ingestion capteur → Kafka (202) |
| `POST` | `/api/v1/alarm/arm` | **Commande** — armer |
| `POST` | `/api/v1/alarm/disarm` | **Commande** — désarmer |
| `POST` | `/api/v1/alerts/{id}/acknowledge` | **Commande** — acquitter |
| `GET` | `/api/v1/alarm/status` | **Requête** — état courant |
| `GET` | `/api/v1/alerts?limit=50` | **Requête** — historique |
| `WS` | `/ws` → `/topic/alerts` | Push temps réel (STOMP) |
Documentation OpenAPI : `/swagger-ui.html`
Déclencher une intrusion à la main :
```bash
curl -X POST http://localhost:8080/api/v1/alarm/arm
sleep 31 # laisser passer le délai de sortie
curl -X POST http://localhost:8080/api/v1/sensor-events \
-H 'Content-Type: application/json' \
-d '{"deviceId":"iphone-biblio","type":"MOTION_DETECTED","confidence":95}'
```
Les erreurs suivent la RFC 7807 (`ProblemDetail`). Une règle métier violée
renvoie **409** et non 400 : la requête est bien formée, c'est l'état qui s'y
oppose.
---
## Production
Déployé sur un serveur Hetzner (Ubuntu, 2 vCPU, 4 Go) mutualisé avec d'autres
services.
- **TLS** — Caddy, déjà en place sur la machine et détenteur des ports 80/443,
obtient et renouvelle les certificats Let's Encrypt automatiquement.
Y démarrer un second frontal les aurait mis en conflit et coupé les services
existants. Le bloc à ajouter est dans [`deploy/Caddyfile.snippet`](deploy/Caddyfile.snippet).
- **Isolation réseau** — Kafka et PostgreSQL vivent sur un réseau Docker privé
sans aucun port publié sur l'hôte. Seule l'application rejoint le réseau
partagé du reverse proxy.
- **Empreinte mémoire** — limites de conteneur explicites (app 512 Mo, Kafka
800 Mo, PostgreSQL 256 Mo). La JVM utilise `MaxRAMPercentage` plutôt que
`-Xmx` : le réglage suit la limite du cgroup sans être défini à deux endroits.
`SerialGC` sur 2 vCPU partagés, où G1 coûte plus en threads qu'il ne rapporte.
- **Supervision** — `/actuator/prometheus` exposé au Prometheus de la machine,
bloqué côté public.
- **Conteneur non-root**, healthchecks sur les trois services, arrêt gracieux.
---
## Stack
| | |
|---|---|
| Langage | Java 21 (records, sealed interfaces, pattern matching, virtual threads) |
| Framework | Spring Boot 3.4 |
| Messagerie | Apache Kafka 3.9 (KRaft) |
| Persistance | PostgreSQL 16 · Spring Data JPA · Flyway |
| Temps réel | WebSocket / STOMP / SockJS |
| Build | Maven · Docker multi-stage |
| Tests | JUnit 5 · AssertJ · **ArchUnit** · Testcontainers |
| Observabilité | Actuator · Micrometer / Prometheus |
---
*Adam Belkacemi — le studio est toujours debout.*