diff --git a/README.md b/README.md index 3302c64..20181cc 100644 --- a/README.md +++ b/README.md @@ -3,21 +3,36 @@ Monorepo de la plateforme EnerVision : collecte, stockage, analyse et restitution de series temporelles energetiques, deployee sur une machine on-premise. +## Jalons + +| Jalon | Intitulé | +|-------|----------------------------------------------------------| +| J1 | Valider la préparation de l'environnement et du repo | +| J2 | Valider le périmètre retenu et les choix technologiques | +| J3 | Valider l'architecture et la gestion de la sécurité | +| J4 | Valider la robustesse et assurer les livrables | + +Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.md](docs/architecture/00-vue-ensemble.md). + ## Stack cible | Domaine | Technologie | Emplacement | Etat | |------------|-------------------------------------|---------------------|---------------| | Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise | -| Frontend | Angular, Node 24 LTS | `apps/frontend` | A initialiser | +| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Squelette | | Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise | | ETL | Apache Airflow | `etl/airflow` | A initialiser | | Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise | | CI/CD | GitHub Actions | `.github/workflows` | A initialiser | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser | -Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Les autres dossiers +Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend +porte le squelette Angular, sans code metier : aucune route, aucun appel d'API. Les autres dossiers portent l'arborescence et un README de cadrage, leur contenu fait l'objet d'un ticket dedie. +L'etat detaille de chaque brique et les vues d'architecture sont dans +[docs/architecture](docs/architecture/README.md). + ## Arborescence ``` @@ -82,3 +97,4 @@ curl -s localhost:8000/api/v1/health/ready - Branches : `feat/`, `fix/`, `chore/`, `docs/`, `test/` suivi d'un libelle court. - Commits : Conventional Commits, portee = dossier de premier niveau concerne. - Toute decision structurante donne lieu a un ADR dans `docs/adr`. +- Toute PR qui change un composant met a jour sa vue dans `docs/architecture`, dans la meme PR. diff --git a/apps/frontend/README.md b/apps/frontend/README.md index ce9c73c..aeaf788 100644 --- a/apps/frontend/README.md +++ b/apps/frontend/README.md @@ -72,7 +72,8 @@ Points à vérifier après toute regénération : 1. Pointer l'API dans `src/environments/` sur `http://localhost:8000/api/v1`. 2. Ajouter le proxy de développement (`proxy.conf.json`) vers le backend. -3. Vérifier que `npm start` sert bien sur le port 4200 attendu par `docker-compose.yml`. +3. Vérifier que `npm start` sert bien sur le port 4200, valeur par défaut d'`APP_CORS_ORIGINS` + côté backend. Le `docker-compose.yml` n'a aucun service frontend. 4. Ajouter le `Dockerfile` multi-stage (build Angular puis service statique nginx). ## Additional Resources diff --git a/apps/frontend/TESTING.md b/apps/frontend/TESTING.md index e23ed7a..d4e92bf 100644 --- a/apps/frontend/TESTING.md +++ b/apps/frontend/TESTING.md @@ -27,6 +27,10 @@ it('devrait faire X quand Y', () => { - Composants avec logique (formulaires, conditions d'affichage) — pas nécessaire pour un composant 100% template, sans logique +`core/services/`, `core/guards/` et `core/interceptors/` n'existent pas encore : c'est +l'arborescence cible, décrite dans +[docs/architecture/30-frontend.md](../../docs/architecture/30-frontend.md). + ## Gabarit — tester un service avec appel HTTP ```typescript import { TestBed } from '@angular/core/testing'; diff --git a/docs/README.md b/docs/README.md index b4ad74d..938a78a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,4 +1,4 @@ # Documentation - `adr` : decisions d'architecture, une par fichier, numerotees et immuables. -- `architecture` : schemas et vues d'ensemble. +- `architecture` : les vues du systeme. Point d'entree : [architecture/README.md](architecture/README.md). diff --git a/docs/architecture/.gitkeep b/docs/architecture/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md new file mode 100644 index 0000000..8da3f0f --- /dev/null +++ b/docs/architecture/00-vue-ensemble.md @@ -0,0 +1,136 @@ +# Vue d'ensemble + +EnerVision collecte, stocke, analyse et restitue des séries temporelles énergétiques, sur une +machine on-premise. + +## Cadre du projet + +Quatre jalons ont été posés à l'ouverture du projet. Ils ont disparu du `README.md` lors de la +réécriture de l'arborescence (`2670483`) et ne subsistaient que sur `main`. Ils sont repris ici +parce qu'ils disent ce que le projet doit prouver, et donc à quoi sert chaque décision technique. + +| Jalon | Intitulé | Ce que la documentation apporte | +|---|---|---| +| J1 | Valider la préparation de l'environnement et du repo | `10-infra.md` décrit la stack du poste de développement et la commande qui la démarre | +| J2 | Valider le périmètre retenu et les choix technologiques | Les ADR (`../adr/`) portent les choix ; `40-data.md` liste les questions de périmètre encore ouvertes | +| J3 | Valider l'architecture et la gestion de la sécurité | Les cinq vues, et la section « Sécurité » ci-dessous qui consolide les surfaces exposées | +| J4 | Valider la robustesse et assurer les livrables | `20-backend.md` et `30-frontend.md` renvoient aux conventions de tests de chaque application | + +## Contexte + +Statut : `Cible`. Les acteurs et les sources de mesures ne sont pas arrêtés, c'est l'objet du +jalon J2. + +```mermaid +flowchart LR + exploitant["Exploitant
consulte les courbes"] + admin["Administrateur
exploite la plateforme"] + sources["Sources de mesures
à définir en J2"] + + subgraph systeme["EnerVision"] + plateforme["Collecte, stockage,
analyse et restitution
de séries temporelles"] + end + + sources -.-> plateforme + exploitant -.-> plateforme + admin -.-> plateforme +``` + +## Conteneurs + +Trait plein pour ce qui tourne, pointillé pour ce qui est cible. + +```mermaid +flowchart TB + navigateur["Navigateur"] + + subgraph machine["Machine on-premise"] + front["Frontend Angular 22
apps/frontend"] + api["API FastAPI
apps/backend"] + db[("PostgreSQL 17
TimescaleDB")] + airflow["Airflow
etl/airflow"] + prom["Prometheus"] + grafana["Grafana"] + end + + navigateur --> front + front -.-> api + api --> db + airflow -.-> db + prom -.-> api + grafana -.-> db + grafana -.-> prom +``` + +Le lien `front -.-> api` est en pointillé à dessein : le frontend n'appelle aujourd'hui aucune +API, `provideHttpClient` n'est pas encore installé. Voir [30-frontend.md](30-frontend.md). + +Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun +collecteur ne vient le lire. + +## État de la stack + +| Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement | +|---|---|---|---|---| +| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`. Aucune couche métier | +| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Squelette `ng new` standalone, routes vides, aucun service HTTP | +| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Aucune table applicative | +| Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée | +| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API | +| ETL | Apache Airflow | `etl/airflow` | `Cible` | Rien | +| CI/CD | GitHub Actions | `.github/workflows` | `Cible` | Rien | + +## Flux bout en bout + +Statut : `Cible`. Aucun maillon de cette chaîne n'existe aujourd'hui, à l'exception de la base. + +```mermaid +sequenceDiagram + participant S as Source de mesures + participant A as Airflow + participant T as TimescaleDB + participant API as FastAPI + participant U as Angular + + S->>A: mesures horodatées + A->>T: insertion dans l'hypertable + T->>T: rafraîchissement de l'agrégat continu + U->>API: GET /api/v1/... + API->>T: agrégation sur la fenêtre demandée + T-->>API: lignes + API-->>U: JSON +``` + +## Sécurité + +Section rattachée au jalon J3. Le détail par brique est dans chaque document ; voici la vue +consolidée. + +### En place + +- **Les secrets n'ont pas de valeur par défaut.** `APP_SECRET_KEY` et `DATABASE_URL` sont requis + sans repli : l'application refuse de démarrer si l'un manque, plutôt que de tourner avec une + valeur de démonstration. `.env` reste hors dépôt, `.env.example` est versionné. +- **CORS conditionnel** : le middleware n'est ajouté que si `APP_CORS_ORIGINS` est renseigné. +- **Documentation interactive fermée en production** : `/docs`, `/redoc` et `/openapi.json` sont + désactivés dès que `APP_ENV=prod`. +- **Conteneur backend non-root**, déclaré dans `apps/backend/Dockerfile`. +- **Côté infrastructure** : la clé SSH est marquée `sensitive`, le kubeconfig reste en `600/root` + sur la machine cible et n'est lu que par `sudo`, `*.tfvars` est ignoré par git sauf les + `.example`. + +### Absent + +- **Aucune authentification ni autorisation.** Les deux endpoints exposés sont publics. Rien + n'est encore décidé sur ce point. +- Pas de TLS, pas de limitation de débit, pas de journalisation des accès, pas de rotation des + secrets. +- Aucune analyse de dépendances ni de conteneur, faute de CI. + +## Décisions structurantes + +Elles vivent dans `../adr/`, pas ici. + +| ADR | Objet | +|---|---| +| [0001](../adr/0001-postgresql-timescaledb.md) | PostgreSQL 17 avec l'extension TimescaleDB, et la frontière `db/` vs `alembic/` | diff --git a/docs/architecture/10-infra.md b/docs/architecture/10-infra.md new file mode 100644 index 0000000..4e82445 --- /dev/null +++ b/docs/architecture/10-infra.md @@ -0,0 +1,132 @@ +# Infrastructure + +Deux topologies coexistent et ne servent pas la même chose. Ce document dit laquelle vaut dans +quel contexte, quelles décisions sont arrêtées, et ce qui manque encore entre les deux. + +| Topologie | Sert à | Statut | +|---|---|---| +| Docker Compose | Développer et recetter sur le poste | `Fait` | +| k3s single-node | Déployer sur le serveur on-premise | `En cours` | + +## Poste de développement + +Statut : `Fait`. Défini par `docker-compose.yml`, projet `enervision`. + +```mermaid +flowchart TB + subgraph poste["Poste de développement"] + ng["ng serve
:4200"] + api["uvicorn --reload
:8000"] + end + + subgraph compose["docker compose"] + back["service backend
image construite depuis apps/backend"] + db[("service db
timescale/timescaledb-ha:pg17")] + end + + ng -.->|"proxy /api"| api + api -->|"hôte :5433 vers conteneur :5432"| db + back -->|"réseau interne, db:5432"| db +``` + +| Service | Image | Points notables | +|---|---|---| +| `db` | `timescale/timescaledb-ha:pg17` | Publié sur **5433** côté hôte, 5432 souvent déjà pris. `healthcheck` `pg_isready`, 12 tentatives, `start_period` 40s | +| `backend` | Construite depuis `apps/backend` | `depends_on: db, condition: service_healthy`. **N'embarque pas le source** : toute modification impose `docker compose up -d --build backend` | + +**La boucle de développement n'utilise pas le service `backend`.** `make db-up` puis `make dev` : +seule la base tourne en conteneur, l'API tourne sur le poste avec le rechargement à chaud. Le +service `backend` sert la stack complète et la recette. Les deux occupent le port 8000, ils ne se +lancent donc pas ensemble. + +Deux pièges sont documentés en tête du `docker-compose.yml`, ils ne se devinent pas : + +- `PGDATA` vaut `/home/postgres/pgdata/data` pour l'image `-ha`, et non le chemin habituel de + l'image `postgres`. Monté ailleurs, le volume ne retient rien, sans le moindre message. +- `db/init` est monté **fichier par fichier**. Monter le dossier masquerait les scripts d'init de + l'image, dont `timescaledb-tune`. Ajouter un fichier dans `db/init/` impose donc une ligne dans + le compose. Voir [`db/README.md`](../../db/README.md). + +## Cible de déploiement + +Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster. Il n'a jamais +été appliqué. + +```mermaid +flowchart LR + poste["Poste
terraform apply"] + kube["kubeconfig local"] + + subgraph serveur["Serveur on-premise"] + k3s["k3s server single-node
Traefik désactivé"] + charges["Charges de travail
aucune déclarée"] + end + + poste -->|"SSH, get.k3s.io"| k3s + k3s -->|"cat /etc/rancher/k3s/k3s.yaml"| kube + k3s -.-> charges +``` + +### Ce que le Terraform fait + +```mermaid +sequenceDiagram + participant TF as terraform apply + participant SRV as Serveur on-premise + participant L as Poste local + + TF->>SRV: SSH, curl get.k3s.io puis install server + TF->>SRV: attend /etc/rancher/k3s/k3s.yaml + TF->>SRV: ssh cat k3s.yaml + SRV-->>L: kubeconfig, 127.0.0.1 réécrit en ssh_host +``` + +### Ce que le Terraform ne fait pas + +Il déclare le provider `null` et **lui seul** : ni `kubernetes`, ni `helm`. Aucun namespace, +aucun déploiement, aucun service, aucun ingress. À l'issue d'un `apply`, on dispose d'un cluster +vide et d'un kubeconfig, rien de plus. + +## Décisions figées + +Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de code et des +`description` de variables, c'est-à-dire qu'ils ne survivaient pas au premier remaniement. + +| Décision | Raison | Où elle est appliquée | +|---|---|---| +| k3s single-node plutôt que Kubernetes complet | Une seule machine on-premise, pas de plan de contrôle à répartir | `modules/k3s/main.tf` | +| `k3s_version` obligatoire, valeur vide refusée | Sans épinglage, `get.k3s.io` installe la dernière version à chaque exécution : le déploiement cesse d'être reproductible | `validation` dans `modules/k3s/variables.tf` | +| Traefik désactivé | Le choix d'ingress reste ouvert, on ne veut pas en subir un par défaut | `k3s_disable_components`, défaut `["traefik"]` | +| Kubeconfig laissé en `600/root`, lu par `sudo` | `--write-kubeconfig-mode 644` exposerait `cluster-admin` à tout utilisateur local de la machine | Commentaire et `fetch_kubeconfig` dans `modules/k3s/main.tf` | +| State Terraform en backend `local` | Un seul opérateur, pas d'exécution concurrente, pas de dépendance à un stockage distant | `environments/dev/versions.tf` | +| `.terraform.lock.hcl` versionné | Fige les versions de provider entre contributeurs et future CI | Commentaire dans `.gitignore` | +| `*.tfvars` ignoré, `*.tfvars.example` versionné | Les tfvars portent l'adresse du serveur et le chemin de la clé | `.gitignore` | +| Désinstallation gérée au `destroy` | `k3s-uninstall.sh` en `on_failure = continue` : un serveur injoignable ne bloque pas le `destroy` | `modules/k3s/main.tf` | +| Deux racines, `dev` et `prod` | Séparation des états et des variables par environnement | `environments/` | + +## Ports et noms + +| Quoi | Valeur | Remarque | +|---|---|---| +| PostgreSQL, côté hôte | `5433` | Redirigé vers 5432 dans le conteneur. 5432 est souvent déjà pris | +| PostgreSQL, côté réseau Compose | `db:5432` | Nom de service, utilisé par `DATABASE_URL` du service `backend` | +| API | `8000` | Identique en conteneur et hors conteneur | +| Frontend, `ng serve` | `4200` | Valeur par défaut d'`APP_CORS_ORIGINS`. Le compose n'a aucun service frontend | +| SSH du serveur | `22` par défaut | `ssh_port`, redéfinissable | +| Base applicative | `enervision` | Variable `POSTGRES_DB` | +| Base de test | `enervision_test` | Créée par `db/init/110-test-database.sql`, nom attendu en dur par `apps/backend/tests/conftest.py` | + +## Le trou entre les deux topologies + +Rien ne relie aujourd'hui ce qui est construit par Compose et ce qui tournerait sur k3s. Compose +construit une image backend localement ; k3s ne saurait pas où la trouver. C'est la première +question à trancher, avant toute ressource Kubernetes. + +## Questions ouvertes + +- **Quel ingress** remplace Traefik, et qui termine le TLS. +- **Quel registre d'images**, et comment il est alimenté sans CI. +- **Quel stockage persistant** côté Kubernetes pour PostgreSQL, et si la base tourne dans le + cluster ou à côté. +- **Quelle stratégie de sauvegarde et de restauration** des données de mesure. +- **Que devient `environments/prod/`**, aujourd'hui réduit à un `.gitkeep`. diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md new file mode 100644 index 0000000..d975da6 --- /dev/null +++ b/docs/architecture/20-backend.md @@ -0,0 +1,163 @@ +# Backend + +API FastAPI, Python 3.14, SQLAlchemy asynchrone sur `asyncpg`. Source dans `apps/backend`. + +## Couches + +La doctrine est posée dans [`apps/backend/README.md`](../../apps/backend/README.md) et +[`TESTING.md`](../../apps/backend/TESTING.md) : `endpoints` appelle `services`, qui appelle +`repositories`, qui seuls touchent les `models`. Le sens de dépendance ne s'inverse jamais. + +Dans les faits, trois de ces couches sont des dossiers vides. + +```mermaid +flowchart TB + ep["endpoints
2 routes"] + sc["schemas
2 modèles Pydantic"] + sv["services
vide"] + rp["repositories
vide"] + md["models
vide"] + db[("PostgreSQL")] + + ep --> sc + ep -.-> sv + sv -.-> rp + rp -.-> md + ep -->|"SQL brut, état actuel"| db + rp -.-> db +``` + +Le trait plein de `endpoints` vers la base n'est pas une erreur de dessin : `/health/ready` +exécute aujourd'hui son `SELECT` directement, sans repository. C'est acceptable pour une sonde +d'infrastructure, qui vérifie la base elle-même et non une donnée métier. Ce raccourci ne doit +pas servir de modèle au premier endpoint métier. + +`app/models/__init__.py` ne contient qu'un avertissement, qui mérite d'être connu avant la +première migration : tout modèle absent de ce module reste invisible d'un +`alembic revision --autogenerate`, qui produirait alors un `drop` de sa table. + +## Démarrage + +Point d'entrée : **une factory**, `uvicorn app.main:create_app --factory`. Aucune configuration +n'est lue à l'import du module, ce qui rend l'application testable et les migrations +indépendantes de l'environnement d'exécution. + +```mermaid +sequenceDiagram + participant U as uvicorn --factory + participant F as create_app + participant S as get_settings + participant A as FastAPI + + U->>F: create_app() + F->>S: Settings depuis .env et variables APP_* + S-->>F: resolved + F->>F: configure_logging(resolved) + F->>A: FastAPI, docs fermés si prod + F->>A: CORSMiddleware, seulement si allowed_origins + F->>A: Instrumentator, expose /metrics + F->>A: include_router, préfixe /api/v1 + A-->>U: application +``` + +**Le `lifespan` n'ouvre aucune connexion.** Au démarrage il journalise le nom, la version et +l'environnement ; à l'arrêt il libère l'engine. L'engine lui-même est construit paresseusement au +premier appel de `get_engine()`, mis en cache par `lru_cache`. Conséquence directe : une API qui +démarre ne prouve rien sur la base, la première connexion réelle a lieu au premier +`GET /api/v1/health/ready`. C'est ce qui rend cette sonde indispensable. + +## Configuration + +`Settings` est un `BaseSettings` Pydantic, lu depuis `.env` avec le préfixe `APP_`. + +| Variable | Défaut | Rôle | +|---|---|---| +| `APP_SECRET_KEY` | **aucun** | Secret applicatif, `SecretStr` | +| `DATABASE_URL` | **aucun** | Chaîne de connexion, `postgresql+asyncpg://...` | +| `APP_ENV` | `local` | `local`, `dev`, `staging` ou `prod` | +| `APP_DEBUG` | `false` | Active aussi l'écho SQL de l'engine | +| `APP_LOG_LEVEL` | `INFO` | | +| `APP_CORS_ORIGINS` | `""` | Liste séparée par des virgules. Vide, aucun middleware CORS n'est posé | +| `APP_API_PREFIX` | `/api/v1` | | +| `APP_DATABASE_POOL_SIZE` | `5` | | +| `APP_DATABASE_MAX_OVERFLOW` | `10` | | + +Deux pièges : + +- **`DATABASE_URL` ne prend pas le préfixe `APP_`.** C'est le seul réglage dans ce cas, par + `validation_alias`, pour rester compatible avec la convention d'Alembic et des hébergeurs. +- **`APP_SECRET_KEY` et `DATABASE_URL` n'ont pas de valeur par défaut.** L'application refuse de + démarrer si l'un manque. C'est délibéré : mieux vaut un échec au démarrage qu'un service qui + tourne avec un secret de démonstration. + +Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docker-compose.yml`, +`apps/backend/.env` alimente l'API lancée sur le poste. + +## Routes exposées + +| Méthode | Chemin | Dans l'OpenAPI | Rôle | +|---|---|---|---| +| GET | `/api/v1/health/live` | oui | Le processus répond. Ne touche pas la base | +| GET | `/api/v1/health/ready` | oui | La base répond **et** l'extension TimescaleDB est chargée | +| GET | `/metrics` | non | Format Prometheus, exposé par l'instrumentator | +| GET | `/docs`, `/redoc`, `/openapi.json` | non | Désactivés quand `APP_ENV=prod` | + +Aucune route métier n'existe à ce jour. + +### `/health/ready` + +Cette sonde porte une garde décrite dans l'[ADR 0001](../adr/0001-postgresql-timescaledb.md) : un +bootstrap de base sauté ne se voit pas au démarrage de l'API, elle le rend visible. + +```mermaid +sequenceDiagram + participant C as Client + participant R as readiness + participant E as get_engine + participant D as PostgreSQL + + C->>R: GET /api/v1/health/ready + R->>E: session, engine créé au premier appel + R->>D: SELECT extversion FROM pg_extension WHERE extname = 'timescaledb' + alt base injoignable + D--xR: SQLAlchemyError ou OSError + R-->>C: 503 Base de donnees injoignable + else extension absente + D-->>R: NULL + R-->>C: 503 Extension TimescaleDB absente + else + D-->>R: version de l'extension + R-->>C: 200 status ready + end +``` + +## Sécurité + +Voir la vue consolidée dans [00-vue-ensemble.md](00-vue-ensemble.md). Côté backend : + +- **Aucune authentification, aucune autorisation.** Les deux routes sont publiques. Le premier + endpoint métier imposera de trancher ce point. +- Le CORS n'autorise que les origines listées, et n'existe pas si la liste est vide. +- `/docs`, `/redoc` et `/openapi.json` disparaissent en production. +- Le conteneur tourne en utilisateur non-root, avec un `HEALTHCHECK` sur `/api/v1/health/live`. +- Ni limitation de débit, ni journalisation des accès, ni en-têtes de sécurité. + +## Observabilité + +- Journalisation par `dictConfig` : format console en développement, JSON dès `APP_ENV=prod`. + `sqlalchemy.engine` est forcé à `WARNING` pour ne pas noyer les journaux. +- `/metrics` au format Prometheus. **Aucun collecteur ne le lit** : `monitoring/` est vide. + +## Tests + +Conventions, gabarits et arborescence : [`apps/backend/TESTING.md`](../../apps/backend/TESTING.md). +Deux points structurants y sont fixés : les doubles passent par `app.dependency_overrides` et +jamais par `unittest.mock`, et les tests qui touchent la vraie base portent le marqueur +`integration`, exclu par défaut. + +## Questions ouvertes + +- **Authentification et autorisation** : quel mécanisme, quelle granularité. +- **Pagination et fenêtrage** des lectures de séries temporelles, qui conditionnent la forme des + endpoints métier. +- **Politique de versionnement de l'API** au-delà du préfixe `/api/v1`. diff --git a/docs/architecture/30-frontend.md b/docs/architecture/30-frontend.md new file mode 100644 index 0000000..98da40a --- /dev/null +++ b/docs/architecture/30-frontend.md @@ -0,0 +1,114 @@ +# Frontend + +Application Angular 22, 100 % standalone, testée avec Vitest. Source dans `apps/frontend`. + +## État actuel + +Statut : `En cours`. Le projet est un `ng new` intact. Le tableau de la +[vue d'ensemble](00-vue-ensemble.md) le classe désormais correctement, le `README.md` racine le +disait encore « à initialiser » alors que le squelette existe depuis `49f4697`. + +Ce qui est en place : + +- Bootstrap par `bootstrapApplication(App, appConfig)`, **aucun `NgModule`** dans le dépôt. +- `app.config.ts` fournit `provideBrowserGlobalErrorListeners()` et `provideRouter(routes)`. +- Vitest via le builder `@angular/build:unit-test`, couverture activée, un fichier de test. +- Prettier configuré, parser `angular` pour les gabarits HTML. + +Ce qui n'existe pas encore : + +- `routes` est un tableau vide. Aucune page, aucune navigation. +- **`provideHttpClient` n'est pas fourni** et `@angular/common/http` n'est importé nulle part : + l'application n'appelle aucune API. +- `app.html` est la page d'accueil Angular par défaut, commentaires de remplacement compris. +- Aucune bibliothèque de graphiques, aucun kit d'interface, aucune gestion d'état. +- Aucun lint : ESLint n'est pas installé. + +## Arborescence cible + +Statut : `Cible`. Elle n'est pas inventée ici : [`TESTING.md`](../../apps/frontend/TESTING.md) la +prescrit déjà dans ses gabarits de tests. + +```mermaid +flowchart TB + subgraph src["src/app"] + core["core/
services, guards, interceptors"] + features["features/
un dossier par domaine"] + shared["shared/
composants réutilisables"] + end + + features -.-> core + features -.-> shared + core -.-> env["environments/
apiUrl"] +``` + +Un service HTTP par domaine dans `core/services`, les composants de page dans `features`, et rien +d'autre que du réutilisable dans `shared`. Les composants n'appellent jamais `HttpClient` +directement : ils passent par un service, ce qui rend le double de test trivial. + +## Flux HTTP + +Statut : `Cible`. Le chemin est câblé, rien ne l'emprunte encore. + +```mermaid +sequenceDiagram + participant C as Composant + participant S as Service Angular + participant P as ng serve, proxy + participant A as FastAPI + + C->>S: appel de méthode + S->>P: GET /api/v1/... + P->>A: http://localhost:8000/api/v1/... + A-->>S: JSON + S-->>C: modèle typé +``` + +En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost:8000`. C'est ce +qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un +`apiUrl` relatif, `/api/v1`. + +En production, il n'y a pas de proxy : `environment.ts` porte une URL absolue. Angular substitue +le fichier via `fileReplacements`, et la configuration `production` est celle par défaut. + +**Dette connue.** `src/environments/environment.ts`, qui est la configuration de production, +pointe `http://localhost:8000/api/v1` en dur. La valeur est celle du poste de développement : +telle quelle, un build de production ne joindra jamais l'API. À corriger avant le premier +déploiement, en même temps que sera tranchée la question de l'ingress dans +[10-infra.md](10-infra.md). + +## Exécution + +| Commande | Effet | +|---|---| +| `npm ci` | Installe les dépendances. `node_modules/` n'est pas présent par défaut | +| `npm start` | `ng serve` sur le port 4200, proxy actif | +| `npm run build` | Build de production | +| `npm run test` | Vitest en mode observateur | +| `npm run test:ci` | Vitest en une passe | + +Le frontend **n'a pas de cible dans le `Makefile` racine** et **aucun service dans +`docker-compose.yml`** : il se pilote uniquement par `npm`, depuis `apps/frontend`. Le port 4200 +n'apparaît dans le compose que comme valeur par défaut d'`APP_CORS_ORIGINS`, côté backend. + +Un `Dockerfile` frontend existe sur la branche `feat/pipeline-cd`, mais il est mono-étage et sans +`CMD` : il construit sans rien servir. Le `README.md` de l'application demande un multi-étage +avec un service statique, il reste à écrire. + +## Sécurité + +- Le frontend ne détient aucun secret : `environment.ts` ne porte qu'une URL. +- L'authentification n'existe pas côté API, donc pas de garde ni d'intercepteur de jeton à ce + stade. `core/guards` et `core/interceptors` sont prévus pour cela. + +## Tests + +Conventions et gabarits : [`apps/frontend/TESTING.md`](../../apps/frontend/TESTING.md). + +## Questions ouvertes + +- **Quelle bibliothèque de graphiques** pour les séries temporelles, et si Grafana en couvre déjà + une partie du besoin. +- **Gestion d'état** : signaux seuls, ou une bibliothèque dédiée. +- **Comment `apiUrl` est injecté en production** : build par environnement, ou configuration lue + au démarrage. diff --git a/docs/architecture/40-data.md b/docs/architecture/40-data.md new file mode 100644 index 0000000..2d53844 --- /dev/null +++ b/docs/architecture/40-data.md @@ -0,0 +1,143 @@ +# Données + +PostgreSQL 17 avec l'extension TimescaleDB. Le choix, ses alternatives et ses conséquences sont +dans l'[ADR 0001](../adr/0001-postgresql-timescaledb.md), qui fait foi. Ce document décrit le +système qui en découle. + +## Avertissement + +**Aucune table applicative n'existe à ce jour.** `Base.metadata` est vide, `app/models/` ne +contient qu'un commentaire, l'unique révision Alembic ne crée aucune table, et aucune hypertable +n'a été déclarée. Tout ce qui suit sous le statut `Cible` est une proposition de structure, pas un +relevé du code. Le modèle sera arrêté au jalon J2. + +## Trois emplacements, trois rôles + +C'est la règle que l'ADR 0001 existe surtout pour fixer. La confondre coûte cher : un script placé +au mauvais endroit ne s'exécute jamais, ou s'exécute deux fois. + +| Emplacement | Contenu | Quand ça s'exécute | +|---|---|---| +| `db/init/` | Extensions, bases annexes | **Une seule fois**, à la première initialisation du conteneur, quand `PGDATA` est vide. Ne rejoue jamais | +| `db/migrations/` | SQL versionné qui ne découle pas du schéma applicatif : rétention, compression | À la main, aujourd'hui vide | +| `apps/backend/alembic/` | Le schéma exposé par l'API, et lui seul | `alembic upgrade head`, c'est `Base.metadata` qui fait foi | + +Une hypertable relève des deux derniers : **Alembic crée la table, et le `create_hypertable()` +vit dans la même révision**. Les séparer rendrait le schéma irreproductible depuis un seul +`alembic upgrade head`. + +Détail de `db/init/` et du piège de montage : [`db/README.md`](../../db/README.md). + +## Ce qui existe + +Statut : `Fait`. + +- `db/init/100-extensions.sql` crée l'extension `timescaledb`. +- `db/init/110-test-database.sql` crée `enervision_test`, dont le nom est attendu en dur par + `apps/backend/tests/conftest.py`. +- Une révision Alembic, `5353c0e4f094`, qui **ne crée aucune table**. Elle établit + `alembic_version` et refuse de s'appliquer si l'extension manque : + +```sql +IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') THEN + RAISE EXCEPTION 'extension timescaledb absente, voir db/init et db/README.md'; +END IF; +``` + +Cette garde forme paire avec le 503 de `/api/v1/health/ready`. Un bootstrap sauté ne se voit pas +au démarrage de l'API : ces deux gardes le rendent visible tôt, des deux côtés. + +## Cycle de vie d'une mesure + +Statut : `Cible`. Aucun de ces maillons n'existe. + +```mermaid +flowchart LR + src["Source de mesures"] -.-> ing["Ingestion Airflow"] + ing -.-> hy[("Hypertable mesure")] + hy -.-> agg[("Agrégat continu")] + hy -.-> comp["Compression"] + hy -.-> ret["Rétention"] + agg -.-> api["API FastAPI"] + agg -.-> graf["Grafana"] +``` + +Les lectures de l'API et de Grafana visent l'agrégat continu, pas la table brute : c'est tout +l'intérêt de TimescaleDB, et cela doit rester vrai quand les volumes augmenteront. + +## Modèle + +Statut : `Cible`. Les entités ci-dessous sont des **candidates**, à valider en J2. Elles +s'appuient sur les gabarits de [`apps/backend/TESTING.md`](../../apps/backend/TESTING.md), qui +évoquent déjà un modèle `Site`, un `SiteRepository` et un `ConsumptionService` exposant un +`total_kwh(site_id)`. + +```mermaid +erDiagram + SITE ||--o{ POINT_DE_MESURE : porte + POINT_DE_MESURE ||--o{ MESURE : produit + + SITE { + int id PK + string nom + } + POINT_DE_MESURE { + int id PK + int site_id FK + string libelle + string unite + } + MESURE { + timestamptz horodatage PK + int point_id PK + double valeur + } +``` + +`MESURE` est la table destinée à devenir une hypertable, partitionnée sur `horodatage`. Sa clé +primaire doit inclure la colonne de temps : TimescaleDB l'exige, une clé sur le seul identifiant +de point serait refusée. + +## Gabarit de révision créant une hypertable + +Conforme à la règle de l'ADR 0001 : table et hypertable dans la même révision. + +```python +def upgrade() -> None: + op.create_table( + "mesure", + sa.Column("horodatage", sa.DateTime(timezone=True), nullable=False), + sa.Column("point_id", sa.Integer(), sa.ForeignKey("point_de_mesure.id"), nullable=False), + sa.Column("valeur", sa.Float(), nullable=False), + sa.PrimaryKeyConstraint("horodatage", "point_id"), + ) + op.execute("SELECT create_hypertable('mesure', by_range('horodatage'))") + + +def downgrade() -> None: + op.drop_table("mesure") +``` + +`drop_table` suffit au retour arrière : supprimer la table supprime l'hypertable et ses partitions. + +## Conventions + +- **Noms au singulier**, en minuscules, sans préfixe de table. +- **Toute colonne de temps en `timestamptz`.** Jamais de `timestamp` nu : une mesure sans fuseau + devient ininterprétable dès le premier changement d'heure. +- **La colonne de partitionnement s'appelle `horodatage`** et entre dans la clé primaire. +- **Les politiques de rétention et de compression** vont dans `db/migrations/`, pas dans Alembic : + elles ne découlent pas du schéma applicatif. +- **Tout modèle doit être importé dans `app/models/__init__.py`**, sans quoi + `alembic revision --autogenerate` ne le voit pas et génère un `drop` de sa table. + +## Questions ouvertes + +Elles relèvent du jalon J2, « valider le périmètre retenu », et bloquent le modèle définitif. + +- **Quelles sources de mesures**, et selon quel protocole elles sont collectées. +- **Quelle granularité** à l'ingestion : la seconde, la minute, le quart d'heure. +- **Quels agrégats continus**, et sur quelles fenêtres. +- **Quelle profondeur de rétention** en données brutes, et à partir de quand on compresse. +- **Quelles unités** sont manipulées, et si une même table les mélange. +- **Multi-tenant ou non** : un site appartient-il à un client, et faut-il cloisonner les lectures. diff --git a/docs/architecture/README.md b/docs/architecture/README.md new file mode 100644 index 0000000..a23be40 --- /dev/null +++ b/docs/architecture/README.md @@ -0,0 +1,58 @@ +# Architecture + +Les vues d'architecture d'EnerVision. Un ADR (`../adr/`) **décide** et date une décision +structurante ; une vue d'architecture **décrit** le système qui en résulte. Quand les deux se +contredisent, c'est l'ADR qui fait foi et la vue qui est en retard. + +## Les documents + +| Document | Ce qu'il couvre | +|---|---| +| [00-vue-ensemble.md](00-vue-ensemble.md) | Jalons du projet, contexte, conteneurs, sécurité, flux bout en bout | +| [10-infra.md](10-infra.md) | Poste de développement, cible k3s, décisions figées, ports et noms | +| [20-backend.md](20-backend.md) | Couches FastAPI, séquence de démarrage, routes, configuration | +| [30-frontend.md](30-frontend.md) | Angular, arborescence cible, flux HTTP | +| [40-data.md](40-data.md) | Frontières `db/` et `alembic/`, cycle de vie d'une mesure, modèle | + +L'observabilité, la sécurité et la CI/CD n'ont pas de document propre : ce sont des sections des +cinq ci-dessus, tant que `monitoring/`, `.github/workflows/` et `etl/airflow/` ne contiennent que +des `.gitkeep`. Elles en sortiront le jour où elles auront de la matière. Un fichier vide de plus +n'aide personne. + +## Conventions + +### Mermaid, et rien d'autre + +GitHub rend Mermaid nativement dans les fichiers `.md`. Un diagramme est donc du texte : il se +relit en revue, il se diffe, et il ne se périme pas dans un binaire que plus personne ne sait +rouvrir six mois plus tard. Aucune image exportée, aucun `.drawio`, aucun `.png`. + +### Chaque section porte son statut + +Une large part de la stack n'est pas écrite. Une vue qui mélange l'existant et la cible sans le +dire devient fausse sans prévenir. + +| Statut | Sens | +|---|---| +| `Fait` | Le code existe et tourne | +| `En cours` | Commencé, incomplet | +| `Cible` | Décidé, pas encore écrit | + +### Légende des diagrammes + +Trait plein pour ce qui tourne, trait pointillé pour ce qui est cible. + +```mermaid +flowchart LR + A[Composant en place] --> B[Composant en place] + B -.-> C[Composant cible] +``` + +## Maintenance + +**Toute PR qui change un composant met à jour sa vue dans la même PR.** Une vue qu'on promet de +mettre à jour plus tard ne l'est jamais. + +Une documentation fausse coûte plus cher qu'une documentation absente : on la lit, on la croit, et +on construit dessus. Si une section ne peut plus être tenue à jour, elle est supprimée plutôt que +laissée à dériver.