Jalon : develop vers main, la chaîne complète et l'application sur le serveur #170
No reviewers
Labels
No labels
Compat/Breaking
EC01
EC02
EC03
EC04
EC05
EC06
Kind/BDD
Kind/Back
Kind/Bug
Kind/CICD
Kind/Cloud
Kind/Contenu
Kind/Data
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Front
Kind/IA
Kind/Infra
Kind/Monitoring
Kind/Security
Kind/Testing
Portée/Post-jury
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
ops/alerte
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
g2/enervision!170
Loading…
Reference in a new issue
No description provided.
Delete branch "develop"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Le serveur est resté au 3 septembre.
mainporte encore4a78583, et le déploiement du 07/09 à 13h30 a rejoué ce socle-là : il a réussi, il n'avait simplement rien de neuf à poser.Cette demande apporte 198 commits, 88 demandes fusionnées, 339 fichiers.
Une action avant de fusionner, et une seule
La clé de signature de l'API n'est dans aucun coffre.
vault_api_jwt_secretfigure dansvault.yml.example, pas dansvault.yml, qui n'a bougé qu'une fois depuismainet pour le jeton d'alerte du #42.Sans elle,
api.envn'est pas rendu et la pileapiest sautée bruyamment, ce qui est le comportement voulu : une API démarrée avec une clé vide accepterait des jetons forgés. Mais cette pile porte deux conteneurs,ev-apietev-dashboard-build. Ni l'API ni la construction du tableau de bord ne tourneraient, c'est-à-dire précisément ce qu'on veut montrer.Le reste du socle part de toute façon : le rôle ne bloque plus sur ce secret, c'est le correctif de la #160.
Ce qui arrive sur le serveur
La chaîne de données, de bout en bout. Zone argent (#123, #143) et zone or (#124, #159) entrent en cron, aux minutes 0, 17 et 27. Les alertes de la source traversent jusqu'à
public.alerte(#167, #169). Le rattrapage ne dégrade plus une passe riche (#162).L'application. API sur les routes L1 à L4 (#137), écrans Parc, Site et Qualité (#139, #140, #155), page de connexion branchée sur l'API réelle (#138).
L'IA. Contrat de prévision H+1 et sa référence publiée (#161), entraînement et promotion du modèle (#166), avec une MAE de 2,018 kW contre 21,443 pour la persistance. Les trois règles de recommandation (#154).
L'exploitation. Cinq alertes notifiées dans la forge (#152), copie des sauvegardes hors serveur (#146), configuration de l'exécuteur versionnée (#145), squelette Terraform et plan de migration chiffré (#150, #163).
Trois migrations s'appliqueront :
0016agrégats,0017référence de prévision,0018libellé et état des alertes. La base en est à 15.Ce qui va démarrer, et ce qui sera sauté
build: true, elles le sont à chaque passagevault_api_jwt_secretapp_compose_wait_timeoutpasse de 180 à 600 s : au premier passage,ev-apiconstruit son environnement Python depuis PyPI etev-dashboard-buildfait unnpm cipuis unvite build. Attendez-vous à un déploiement long, cinq à dix minutes.Ce qui a été vérifié avant d'ouvrir
Le risque, et la reprise
Le déploiement continu joue
--tags app,proxyet recrée PostgreSQL. Le volume survit, les connexions tombent. Le collecteur écrit dans MinIO et non en base : la relève de la minute n'est pas menacée, c'est mesuré sur le déploiement de 13h30.Si ça tourne mal,
mainrevient à4a78583et le déploiement se rejoue : le socle du 3 septembre est reconstructible, il l'a été il y a une heure.Critères de sortie
vault_api_jwt_secretest au coffre, 32 caractères minimumfailed=0https://app.g2.enervision/api/v1/sitesrépond 200, et non 502https://app.g2.enervisionsert le tableau de bord0018comprisegold-dailyetload-postgrescomprisesQuatre entrées, dans un service nouveau : `services/etl/` n'existait pas, le README racine n'attribuait aucun chemin à l'agrégation, et la mettre sous `services/collector/` aurait fait du collecteur « collecte + imputation + agrégation + chargement ». etl.agregation zone argent → zone or, `qualite_jour` comprise etl.chargement zone or → `mesure` et `qualite_jour`, en upsert etl.export export de reporting horaire, et sa reconstitution etl.lignage contrôle ENF-07 : remonte un échantillon jusqu'au bronze **La zone or n'est pas un agrégat de plus.** `public.mesure` porte la même série au pas de la minute que la zone argent, sur la même clé, sans table de correspondance : c'est cette identité de bout en bout qui rend l'ENF-07 vérifiable par échantillon. Un agrégat horaire à la place aurait coupé le lignage à l'endroit exact où l'exigence le demande. Les agrégats sont dérivés, en base, et c'est la migration 0016 qui les pose. **Le chargement refuse les jours déjà comprimés.** Un `on conflict do update` qui retombe dans un fragment comprimé décomprime les segments touchés, pour un coût sans rapport avec le nombre de lignes chargées — 35 Mo pour 5,4 Mo, relevé le 3 septembre sur `enervision_preprod`. Recharger un vieux jour reste possible, avec `--forcer` : c'est un geste délibéré, pas un effet de bord d'un job de cron. Le message porte la commande de décompression. **Trois choses que la mise en place a révélées, et qui valent plus que le code.** 1. DuckDB rendait les horodatages dans le fuseau de la machine. Le même jour exporté depuis un poste en CEST et depuis le serveur en UTC donnait deux fichiers différents — ce qui suffit à casser le « reconstituable à la mesure près » du ticket. `SET TimeZone = 'UTC'` est posé dans la connexion. 2. `pytz` est une dépendance que rien n'importe. DuckDB en a besoin pour rendre un `TIMESTAMPTZ` en objet Python : sans lui, le job échoue au `fetchall` du chargement, donc après l'agrégation, sur une machine où tout paraissait installé. Un cas de test garde la dépendance, sinon quelqu'un la retirera comme inutilisée. 3. La lecture en partitionnement Hive s'active toute seule dès qu'un segment de chemin ressemble à `clé=valeur`, et ajoute `domain`, `table` et `dt` aux colonnes du fichier. Le chargement nomme donc ses colonnes une par une. Le job échoue plutôt que d'écrire une journée douteuse : au-delà de 30 % de trous (§11), sur une valeur d'énumération que la base refuserait, ou sur plus de relevés disponibles que la cadence n'en permet — un doublon de zone argent donnerait sinon un taux de disponibilité au-dessus de 100 %. 62 cas unitaires, 91 % de couverture sur le service, plus la chaîne entière sur une journée de 1 440 minutes dans `tests/integration/gold/` — le répertoire que le ticket nomme. Les fixtures de zone argent sont construites en Parquet local et non versionnées : `.gitignore` exclut `*.parquet`, et un binaire de test que personne ne peut relire est un binaire que personne ne corrige. Le job d'agrégation lit le schéma du §10, qui est figé : il tourne donc dès que le #34 livre une zone argent réelle, sans rien changer ici.GET /api/v1/sites/{id}/mesures — un appel par site affiché, comme l'annote la maquette #24. Chaque site est mis à l'échelle du profil du parc plutôt que de porter sa propre courbe inventée (aucune donnée historique par site n'existe encore dans le jeu de démonstration). Un site sans consommation courante (capteur muet, SITE006) lève SourceIndisponible côté dépôt, traduite en 503 côté route : le front garde les autres sites affichés, comme l'état « source indisponible » de la maquette. Pas de reference_kw par site : la référence de comparaison (EF-07) n'est publiée qu'au parc, en inventer une par site afficherait un nombre que personne n'a calculé. 19 tests neufs, couverture dashboard/ à 100 %. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GnUAobdPeQynk8EnCeMLdWLe critère 2 du ticket demande la latence de lecture d'une fenêtre de 24 h sous charge légère. Le cas était bien construit — vingt exécutions, p95, relevé horodaté non versionné — mais son chiffre ne pouvait pas servir de preuve, pour les trois raisons que Justine a relevées. Elles sont traitées dans l'ordre de leur importance, qui n'est pas celui où elles se voient. **1. L'objet mesuré.** La requête recalculait un `time_bucket` sur `public.mesure`, alors que sa définition est **exactement** celle de `public.mesure_horaire`, l'agrégat continu que la 0016 livre dans cette même demande. Un chiffre relevé sur un objet que l'API ne servira pas ne dit rien de ce que l'utilisateur attendra. Deux relevés sont maintenant exigés, un par vue du tableau de bord : Parc entier, agrégat horaire public.mesure_horaire Un site, série à la minute public.mesure Le recalcul direct reste mesuré et imprimé, **sans être exigé** : l'écart entre lui et l'agrégat est ce qui justifie la migration 0016, et un lecteur du ticket doit pouvoir le vérifier au lieu de me croire. **2. Le volume.** 2 880 lignes mettaient le résultat sous la cible d'avance. Le jeu passe à sept jours et aux sept sites du référentiel, soit 70 560 lignes chargées par le fichier lui-même — indépendamment de ce que la base porte déjà — et le relevé imprime les deux comptes : le total de `public.mesure` et la part qui tombe dans la fenêtre mesurée. Deux journées auraient suffi à ce que la fenêtre de 24 h soit **pleine**, et c'est un défaut que je n'avais pas vu : elle commence en milieu de journée d'hier, la version précédente ne chargeait qu'aujourd'hui, donc ne mesurait que les heures écoulées depuis minuit. **3. La concurrence.** Quatre lecteurs simultanés, chacun sur sa connexion, chacun ses vingt exécutions, durées mises en commun. Quatre parce que le tableau de bord a une poignée d'utilisateurs et que le serveur partage 8 Go : au-delà on mesurerait la machine. **Un défaut de la base attrapé en écrivant :** `refresh_continuous_aggregate` ne s'exécute **pas** dans une transaction, et psycopg en ouvre une dès le premier ordre. Le cas de concordance de l'agrégat l'appelait sur la connexion ordinaire : il aurait échoué à la première exécution contre un vrai PostgreSQL, sur un message qui ne parle pas de transaction. Une seconde connexion en `autocommit` lui est dédiée. Un cas s'ajoute au passage, que les sept sites rendent possible : le taux de charge de **chaque** site reste sous sa capacité déclarée. Tant que le jeu ne portait que SITE001 et SITE002, un forfait de 600 kW sur SITE006 — 180 kW au référentiel — aurait donné un taux de 330 % sans que rien ne rougisse. Ces cas se sautent toujours faute de base joignable depuis ce poste : le chiffre reste à relever sur la préproduction, une fois les migrations 0008–0016 appliquées (#114). Ce qui est vérifié ici est ce qui peut l'être — la chaîne de préparation du jeu, jouée à blanc : 70 560 lignes de zone argent construites, projetées et contrôlées, sept lignes de qualité par jour à 1 440 relevés attendus et 32 manquants. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>Sortir vault_forge_alerte_token de l'assertion d'entrée était le bon geste, mais supervision.env.j2 l'interpole toujours sans garde et la clé n'est dans aucun coffre : vault.yml est identique à celui de develop, develop ne référence la variable nulle part, et le compte de service ci-alertes n'existe pas encore. Rejoué en local sur le gabarit réel, sans la clé : fatal: [localhost]: FAILED! => {"censored": "the output has been hidden due to the fact that 'no_log: true' was specified for this result"} 'vault_forge_alerte_token' is undefined La tâche « Rendre les secrets de supervision » est la dixième sur trente du rôle app. Tout ce qui suit ne tourne pas : MinIO, le certificat TLS, les quatre piles, l'environnement Python, la crontab, les migrations. Et comme deploy.yml joue --tags app,proxy, c'est le déploiement continu entier. La garde est dans le gabarit et pas dans defaults/main.yml : ansible-lint exige le préfixe app_ pour toute variable posée dans les défauts d'un rôle (var-naming[no-role-prefix], profil moderate), ce qui aurait fait rougir la chaîne. Le gabarit est de toute façon l'endroit où le caractère facultatif du jeton se lit. Éprouvé dans les deux sens : sans la clé le rendu donne FORGE_ALERTE_TOKEN=, avec une clé au coffre il donne sa valeur. ansible-lint passe au profil production, tests/ci/test-supervision.sh reste vert.Deux défauts relevés à la première exécution réelle, le 7 septembre, et que seul un vrai modèle pouvait montrer. `register_model("runs:/<id>/modele")` ne visait rien. MLflow 3 ne range plus un modèle sous les artefacts de son exécution mais comme une entité à lui, d'URI `models:/m-<id>` : le chemin n'existait pas, et l'enregistrement ne réussissait que par le repli du serveur — « Run with id ... has no artifacts at artifact path 'modele', registering model based on models:/m-... instead ». Un repli qui avertit aujourd'hui est un échec demain. L'enregistrement passe désormais par `registered_model_name` dans `log_model`, dont le retour porte la version ; le repli sur `info.model_uri` couvre une version de MLflow qui ne la porterait pas, et c'est l'URI que le serveur vient de créer, pas un chemin reconstruit. La signature et l'exemple d'entrée décrivaient le même objet de deux façons. Signature déduite d'une liste de listes, exemple donné en tableau NumPy : MLflow refusait de valider l'exemple qu'il venait d'écrire — « Invalid input. Invalid object type at position 0 ». Les deux viennent maintenant du même tableau. NumPy est déclaré au manifeste pour cette ligne, même si scikit-learn l'installerait de toute façon : on déclare ce qu'on importe, comme `pytz` dans l'ETL. ÉPROUVÉ SUR LE SERVEUR, trois exécutions successives sur `enervision_preprod`, bornes 2026-07-30 → 2026-09-04 : plus aucun avertissement, versions 1 puis 2 puis 3 enregistrées, alias `production` déplacé à chaque fois — et la lecture de la version en place avant déplacement donne bien « 2 -> 3 », ce que la première exécution ne pouvait pas montrer faute de version précédente. MAE identique au millième aux trois exécutions, 2,018 kW contre 21,443 kW pour la persistance : la rejouabilité du quatrième critère est constatée, pas supposée. Le chargement par alias est éprouvé pour la première fois du projet : `models:/enervision-prevision-h1@production` rend un PyFuncModel et sa version. C'est la seule commande de `docs/runbooks/mlflow.md` §5 qui n'avait jamais pu être jouée — « elle le sera au premier modèle du #36 » — et c'est la couture que le #37 empruntera. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>