Compare commits

..
Author SHA1 Message Date
Johan LEROY 6c09beeb3c fix(etl): demander la mesure de l'heure pile à l'API Mock
Correctif de cbbfaf4, dont le message affirmait à tort une mesure à :00. Avec
--limit 1, l'API Mock renvoie le point de début d'intervalle : le DAG, déclenché
à :45 sur [:45 - 1 h, :45], aurait écrit ses mesures à :45, au pas horaire mais
hors de la grille du dataset historique.

L'intervalle part désormais de l'heure pile du déclenchement : un run à 13:45
demande [13:00, 13:45] et importe la mesure de 13:00, avant ml_score à 14:00.
Vérifié contre l'API Mock en recette et par le rendu du gabarit Jinja.
2026-09-23 15:39:04 +02:00
Johan LEROY cbbfaf4910 fix(etl): importer une seule mesure par heure depuis l'API Mock
L'API Mock ne renvoie pas les mesures d'une période : elle génère `limit` points
répartis sur l'intervalle demandé (1 000 par heure avec --limit 1000, un toutes
les 3,6 s). Le DAG aurait écrit 7 000 lignes par heure et par environnement,
alors que le dataset historique a une mesure horaire et que les features ML
décalent par ligne : `shift(168)`, le retard d'une semaine, serait devenu un
retard de dix minutes, sans erreur visible au scoring ni au réentraînement.

Avec --limit 1, l'API renvoie la mesure de :00 de chaque heure, au pas du CSV.
Constaté sur la recette le 23/09 avant la réactivation des DAGs.
2026-09-23 15:31:51 +02:00
Johan LEROY c2f360c591 fix(auth): ne plus redemander le mot de passe provisoire à la première connexion
L'écran de changement imposé redemandait le mot de passe provisoire qui venait
d'être vérifié, sans champ identifiant. Un gestionnaire de mots de passe y
collait un ancien mot de passe du site : /auth/password répondait 401
« Identifiants invalides », et le message unique accusait aussi la politique
de mot de passe. Constaté en rec et en dev sur les comptes nominatifs.

- AuthService garde en mémoire le mot de passe d'une connexion qui impose le
  changement, rendu une seule fois par takeProvisionalPassword() et effacé
  avec la session.
- Le champ « Mot de passe actuel » ne s'affiche que si ce mot de passe manque
  (page rechargée) ou vient d'être refusé.
- Champ identifiant masqué pour les gestionnaires de mots de passe.
- Messages distincts pour 401, 422 et le reste, liste des critères en direct.
2026-09-23 14:42:37 +02:00
Johan LEROYandGitHub 933f0a3360 Merge pull request #159 from ineszang/fix/prod-sous-domaine
fix(deploy): la prod passe sur prod.enervision-g3.dynv6.net
2026-09-23 12:40:44 +02:00
Johan LEROY dbcd5c4240 fix(deploy): passe la prod sur prod.enervision-g3.dynv6.net
dynv6 sert mal un TXT _acme-challenge à la racine de la zone : l'API ne
le liste ni ne le supprime, et un seul de ses trois serveurs le renvoie.
Le défi DNS-01 de la prod échouait donc à chaque essai, alors que rec. et
dev. passaient. La prod rejoint ses voisines en sous-domaine, ce qui aligne
aussi les trois noms sur les environnements.

- provision-host.sh : hôte prod.$DOMAINE, enregistrement A prod publié.
- Makefile : --dnssleep 90, le temps que les trois serveurs de dynv6
  servent le TXT avant la validation multi-réseaux de Let's Encrypt.
- deploy.yml, ADR 0018, 10-infra.md, infra/README.md, Terraform.
2026-09-23 12:35:20 +02:00
Johan LEROY 46d10209f1 Merge branch 'main' into dev 2026-09-23 12:01:54 +02:00
Johan LEROYandGitHub db6ee6e56d Merge pull request #156 from ineszang/feat/domaine-duckdns-tls
feat(deploy): URL sans port et certificats Let's Encrypt sur la VM ENI
2026-09-23 12:01:41 +02:00
Johan LEROYandGitHub 84969d3375 Merge pull request #158 from ineszang/dependabot/npm_and_yarn/tests/e2e/e2e-dependencies-b7ceb5d816
chore(deps-dev): bump typescript from 6.0.3 to 7.0.2 in /tests/e2e in the e2e-dependencies group
2026-09-23 11:59:01 +02:00
Johan LEROYandGitHub 93d5cad5af Merge pull request #157 from ineszang/dependabot/github_actions/astral-sh/setup-uv-10.1.0
chore(deps): bump astral-sh/setup-uv from 7.6.0 to 10.1.0
2026-09-23 11:58:56 +02:00
Johan LEROY 4d88604a07 fix(deploy): rejoue la synchronisation dynv6 quand l'API ne répond pas
L'API dynv6 laisse par intermittence une écriture sans réponse, parfois
appliquée malgré tout. La synchronisation est rejouée jusqu'à trois fois
et relit l'état avant chaque écriture : une création aboutie malgré le
délai n'est jamais dupliquée. Délai par appel porté à 60 s.

Validé contre le vrai dynv6 depuis la VM : zone, rec et dev visent
10.101.200.37, et un certificat de test Let's Encrypt a été émis par
DNS-01 pour dev.enervision-g3.dynv6.net.
2026-09-23 11:56:33 +02:00
Johan LEROY 22a88e193c fix(deploy): passe à dynv6 et rend le défi DNS-01 indépendant du fournisseur
deSEC n'ouvre plus de nouveaux domaines dedyn.io, et duckdns.org est
filtré par l'école. dynv6 répond depuis les postes et depuis la VM.

- Zone enervision-g3.dynv6.net ; provision-host.sh pointe la zone, rec
  et dev vers la VM par l'API dynv6 (bloc Python, idempotent).
- make tls-dns01 remplace tls-desec : DNS01_API et DNS01_JETON_VAR
  nomment le greffon acme.sh, le jeton vit dans ../dns.token quel que
  soit le fournisseur. Un domaine acheté ne demandera que ces variables.
- deploy.yml ne demande un certificat qu'à un .env qui ne porte plus de
  nom en .local.
- ADR 0018 renommé noms-publics : deSEC et DuckDNS en alternatives.
2026-09-23 11:47:07 +02:00
Johan LEROY d687d7dc58 fix(deploy): passe de DuckDNS à deSEC, filtré par l'école
Le filtrage du réseau de l'école bloque duckdns.org, site et API, depuis
les postes comme depuis la VM : sans API, pas de défi DNS-01. deSEC
(dedyn.io) répond depuis les deux.

- Domaine enervision-g3.dedyn.io ; provision-host.sh publie par l'API
  deSEC l'enregistrement du domaine et son joker vers la VM.
- make tls-desec remplace tls-duckdns. acme.sh recopie le jeton dans
  acme/account.conf : le dossier est retiré aux autres comptes.
- deploy.yml ne demande un certificat qu'à un .env déjà réaligné sur
  le domaine deSEC, pour ne pas faire échouer un déploiement en cours
  de migration.
2026-09-23 11:38:44 +02:00
dependabot[bot]andGitHub 288970df77 chore(deps-dev): bump typescript
Bumps the e2e-dependencies group in /tests/e2e with 1 update: [typescript](https://github.com/microsoft/TypeScript).


Updates `typescript` from 6.0.3 to 7.0.2
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v6.0.3...v7.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 7.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: e2e-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-23 09:34:42 +00:00
dependabot[bot]andGitHub 985c188106 chore(deps): bump astral-sh/setup-uv from 7.6.0 to 10.1.0
Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 7.6.0 to 10.1.0.
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/37802adc94f370d6bfd71619e3f0bf239e1f3b78...bec219d24cd3e171d82865faccec33120bb574f4)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: 10.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-23 09:34:37 +00:00
PhyriosandGitHub b786f27a4d Merge pull request #152 from ineszang/dev
Remontée dev vers main : mise en production sur la VM ENI
2026-09-23 11:33:44 +02:00
Johan LEROY 3e871a3e8b feat(deploy): URL sans port et certificats Let's Encrypt sur la VM ENI
Les trois environnements passent sur enervision-g3.duckdns.org, rec. et
dev. : noms publics qui visent l'IP privée de la VM, donc résolus sans
/etc/hosts sur le réseau de l'école et injoignables ailleurs (ADR 0018).

- infra/front : nginx sur le réseau de l'hôte, seul exposé en 80 et 443.
  Aiguille par SNI vers la stack visée sans déchiffrer le TLS, et lui
  transmet l'IP du client en PROXY protocol.
- Proxy de stack : écouteur 4443 en PROXY protocol, real_ip_header ;
  sans lui, limit_req et get_client_ip() compteraient tous les postes
  comme un seul. PROXY_FRONT_PORT le publie sur 127.0.0.1.
- make tls-duckdns : Let's Encrypt par défi DNS-01 via l'API DuckDNS
  (acme.sh 3.1.6), rejouable, rejoué à chaque déploiement et chaque nuit.
- provision-host.sh fait foi pour l'adressage et les secrets : un .env
  existant garde ses secrets, reçoit ceux qui manquent (supervision) et
  voit hôte et ports réalignés. Planifie le renouvellement des certificats.
- deploy.yml : nouvelles URL, sonde prod sur 10443, front-up en prod.
- Terraform : variable domaine. CI : validation du frontal.
2026-09-23 11:21:12 +02:00
Johan LEROYandGitHub fe0d4222a5 Merge pull request #148 from ineszang/feat/robustesse-ci-e2e-charge-supervision
Robustesse : CI/CD unifiée, e2e Playwright, charge k6, supervision
2026-09-23 10:57:32 +02:00
32 changed files with 986 additions and 154 deletions
+3
View File
@@ -69,6 +69,9 @@ PUBLIC_ORIGIN=
# l'extérieur. Décaler aussi POSTGRES_PORT, MAILPIT_UI_PORT et AIRFLOW_PORT (5434, 8026, 8082).
PROXY_HTTP_PORT=
PROXY_HTTPS_PORT=
# Écouteur PROXY protocol du proxy, que seul le frontal de la VM joint (infra/front, ADR 0018).
# Vide : port aléatoire sur 127.0.0.1. VM : 127.0.0.1:10444 en prod, 8444 en recette, 9444 en dev.
PROXY_FRONT_PORT=
# Réglages mémoire de la stack déployée. Sans eux, timescaledb-tune réserve 25 % de la RAM de la
# machine à chaque base au premier démarrage. L'api-server Airflow 3 n'a rien à régler ici : son
# nombre de workers vaut 1 par défaut, contre 4 pour le webserver d'Airflow 2.
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
# Action tierce, épinglée sur le commit du tag (règle Sonar githubactions:S7637).
- name: Installe uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true
cache-dependency-glob: etl/airflow/uv.lock
+4 -4
View File
@@ -26,7 +26,7 @@ jobs:
# Action tierce, épinglée sur le commit du tag (règle Sonar githubactions:S7637).
- name: Installe uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
@@ -95,7 +95,7 @@ jobs:
uses: actions/checkout@v7
- name: Installe uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
@@ -133,7 +133,7 @@ jobs:
uses: actions/checkout@v7
- name: Installe uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
@@ -161,7 +161,7 @@ jobs:
# Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
- name: Installe uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: false
+1
View File
@@ -81,6 +81,7 @@ jobs:
compose:
- "docker-compose*.yml"
- ".env.example"
- "infra/front/**"
- "monitoring/**"
- ".github/workflows/infra.yml"
workflows:
+1 -1
View File
@@ -79,7 +79,7 @@ jobs:
- name: Installe uv
# Épinglé sur le commit du tag v7 (règle Sonar githubactions:S7637 : dépendance tierce,
# contrairement à actions/checkout ou actions/upload-artifact, premières parties).
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
+5 -2
View File
@@ -28,10 +28,10 @@ jobs:
timeout-minutes: 30
environment:
name: ${{ github.ref_name == 'main' && 'prod' || github.ref_name == 'dev' && 'rec' || 'dev' }}
url: ${{ github.ref_name == 'main' && 'https://enervision.local' || github.ref_name == 'dev' && 'https://rec.enervision.local:8443' || 'https://dev.enervision.local:9443' }}
url: ${{ github.ref_name == 'main' && 'https://prod.enervision-g3.dynv6.net' || github.ref_name == 'dev' && 'https://rec.enervision-g3.dynv6.net' || 'https://dev.enervision-g3.dynv6.net' }}
env:
ENVIRONNEMENT: ${{ github.ref_name == 'main' && 'prod' || github.ref_name == 'dev' && 'rec' || 'dev' }}
PORT_HTTPS: ${{ github.ref_name == 'main' && '443' || github.ref_name == 'dev' && '8443' || '9443' }}
PORT_HTTPS: ${{ github.ref_name == 'main' && '10443' || github.ref_name == 'dev' && '8443' || '9443' }}
steps:
# Un seul step : le verrou tombe avec le shell qui l'a posé.
- name: Déploie le commit testé, sans jamais reculer
@@ -54,7 +54,10 @@ jobs:
echo "::endgroup::"
echo "::group::Reconstruit et redémarre la stack"
# Un `.env` pas encore réaligné par provision-host.sh porte encore un nom en `.local`.
if [ -r ../dns.token ] && ! grep -q '^PUBLIC_HOST=.*\.local$' .env; then make tls-dns01; fi
make stack-up
if [ "${ENVIRONNEMENT}" = prod ]; then make front-up; fi
echo "::endgroup::"
echo "::group::Attend que l'API réponde derrière le proxy"
+5
View File
@@ -73,6 +73,11 @@ jobs:
- name: Valide la stack déployée, profils compris
run: docker compose -f docker-compose.yml -f docker-compose.prod.yml --profile acme --profile monitoring --profile load config --quiet
- name: Valide le frontal SNI de la VM
run: |
docker compose -f infra/front/compose.yml config --quiet
docker run --rm -v "$PWD/infra/front/nginx.conf:/etc/nginx/nginx.conf:ro" nginx:1.31-alpine nginx -t
# Mêmes commandes que `make monitoring-check` : images et montages viennent du fichier Compose.
- name: Valide la configuration de Prometheus et ses règles
run: docker compose --profile monitoring run --rm --no-deps --entrypoint promtool prometheus check config /etc/prometheus/prometheus.yml
+3 -3
View File
@@ -26,7 +26,7 @@ jobs:
# Action tierce, épinglée sur le commit du tag (règle Sonar githubactions:S7637).
- name: Installe uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true
cache-dependency-glob: ml/uv.lock
@@ -95,7 +95,7 @@ jobs:
uses: actions/checkout@v7
- name: Installe uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true
cache-dependency-glob: |
@@ -155,7 +155,7 @@ jobs:
# Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
- name: Installe uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: false
+1
View File
@@ -75,6 +75,7 @@ etl/airflow/tests/.airflow_home/
# TLS : certificats du reverse proxy, générés par script ou par certbot
infra/proxy/tls/*.pem
infra/proxy/acme/
# IDE et OS
.idea/
+28 -1
View File
@@ -73,7 +73,7 @@ DEMO_NOW ?= 2024-12-31T00:00:00Z
migrate migrate-test bootstrap-admin services-up demo-data demo-data-force \
ml-lint ml-typecheck ml-test ml-check ml-train ml-score mlflow-up detect-alerts recommendations \
airflow-lint airflow-test airflow-check airflow-up airflow-down airflow-logs \
tls-selfsigned tls-acme tls-renew stack-up stack-down stack-logs \
tls-selfsigned tls-acme tls-renew tls-dns01 front-up stack-up stack-down stack-logs \
e2e-install e2e-prepare e2e load-smoke load-test load-stress load-limits \
db-ensure-supervision monitoring-up monitoring-down monitoring-logs monitoring-check
@@ -235,6 +235,33 @@ tls-renew: ## Renouvelle les certificats Let's Encrypt et recharge le proxy
$(COMPOSE_PROD) --profile acme run --rm certbot renew --deploy-hook /deploy-hook.sh
$(COMPOSE_PROD) exec proxy nginx -s reload
# Pourquoi : la VM n'a qu'une IP privée, que Let's Encrypt ne joint pas ; le défi DNS-01 passe
# par l'API du fournisseur DNS, dynv6 par défaut (ADR 0018). Le jeton ne passe jamais par `argv`.
ACME_SH := neilpang/acme.sh:3.1.6
DNS01_API ?= dns_dynv6
DNS01_JETON_VAR ?= DYNV6_TOKEN
DNS01_JETON_FICHIER ?= $(abspath $(CURDIR)/../dns.token)
acme-sh = docker run --rm --user "$$(id -u):$$(id -g)" -e $(DNS01_JETON_VAR) -e AUTO_UPGRADE=0 \
-v "$(CURDIR)/infra/proxy/acme:/acme.sh" -v "$(CURDIR)/infra/proxy/tls:/tls" $(ACME_SH)
# acme.sh sort en 2 quand le certificat n'est pas à renouveler, et recopie le jeton dans
# acme/account.conf, d'où le chmod. `--dnssleep` : Let's Encrypt valide depuis plusieurs réseaux.
tls-dns01: ## Certificat Let's Encrypt par DNS-01, renouvelé seulement à échéance. Jeton : ../dns.token
@case "$(PUBLIC_HOST)" in *.local | localhost) echo "PUBLIC_HOST=$(PUBLIC_HOST) n'est pas un nom public"; exit 1 ;; esac
@test -r "$(DNS01_JETON_FICHIER)" || { echo "Jeton DNS illisible : $(DNS01_JETON_FICHIER)"; exit 1; }
@mkdir -p infra/proxy/acme && chmod 700 infra/proxy/acme
@$(DNS01_JETON_VAR)="$$(tr -d '[:space:]' < "$(DNS01_JETON_FICHIER)")"; export $(DNS01_JETON_VAR); \
$(acme-sh) --issue --server letsencrypt --dns $(DNS01_API) --dnssleep 90 -d "$(PUBLIC_HOST)"; \
code=$$?; chmod -R go-rwx infra/proxy/acme; [ $$code -eq 0 ] || [ $$code -eq 2 ] || exit $$code
@$(acme-sh) --install-cert --ecc -d "$(PUBLIC_HOST)" \
--fullchain-file /tls/fullchain.pem --key-file /tls/privkey.pem
@$(COMPOSE_PROD) exec -T proxy nginx -s reload 2>/dev/null \
|| echo "Proxy arrêté : il lira le certificat à son démarrage"
front-up: ## Démarre ou recharge le frontal SNI de la VM, sur les ports 80 et 443 de l'hôte
docker compose -f infra/front/compose.yml up -d
docker compose -f infra/front/compose.yml exec -T front nginx -s reload
e2e-install: ## Installe Playwright et Chromium pour les tests de bout en bout
cd $(E2E) && npm ci && npx playwright install chromium
@@ -43,6 +43,31 @@ describe('AuthService', () => {
expect(service.isAuthenticated()).toBe(true);
});
it('garde le mot de passe provisoire pour un seul changement quand il doit être changé', () => {
service.login({ email: 'a@a.com', password: 'Provisoire' }).subscribe();
httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush({
...tokenResponse,
principal: { ...tokenResponse.principal, must_change_password: true },
});
expect(service.takeProvisionalPassword()).toBe('Provisoire');
expect(service.takeProvisionalPassword()).toBeNull();
});
it('ne garde aucun mot de passe quand il est déjà définitif, ni après la fin de session', () => {
service.login({ email: 'a@a.com', password: 'Definitif' }).subscribe();
httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush(tokenResponse);
expect(service.takeProvisionalPassword()).toBeNull();
service.login({ email: 'a@a.com', password: 'Provisoire' }).subscribe();
httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush({
...tokenResponse,
principal: { ...tokenResponse.principal, must_change_password: true },
});
service.clearSession();
expect(service.takeProvisionalPassword()).toBeNull();
});
it('efface la session au logout', () => {
service.login({ email: 'a@a.com', password: 'secret' }).subscribe();
httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush(tokenResponse);
@@ -19,6 +19,9 @@ export class AuthService {
// mémoire. Un rechargement de page le perd, c'est voulu par le contrat.
private accessTokenSignal = signal<string | null>(null);
private principalSignal = signal<Principal | null>(null);
// Pourquoi : redemander le mot de passe provisoire qu'on vient de vérifier laisse un gestionnaire
// de mots de passe y coller un ancien mot de passe du site, et `/auth/password` répond 401.
private provisionalPassword: string | null = null;
readonly principal = this.principalSignal.asReadonly();
readonly isAuthenticated = computed(() => this.principalSignal() !== null);
@@ -37,12 +40,26 @@ export class AuthService {
clearSession(): void {
this.accessTokenSignal.set(null);
this.principalSignal.set(null);
this.provisionalPassword = null;
}
login(credentials: LoginRequest): Observable<TokenResponse> {
return this.http
.post<TokenResponse>(`${environment.apiUrl}/auth/login`, credentials, { withCredentials: true })
.pipe(tap((response) => this.setSession(response)));
.pipe(
tap((response) => {
this.setSession(response);
this.provisionalPassword = response.principal.must_change_password
? credentials.password
: null;
})
);
}
takeProvisionalPassword(): string | null {
const password = this.provisionalPassword;
this.provisionalPassword = null;
return password;
}
// Un seul rafraîchissement en vol à la fois, partagé entre tous les
@@ -7,14 +7,18 @@
Votre mot de passe est provisoire, vous devez le modifier avant de continuer
</p>
<label class="form-label" for="current_password">Mot de passe actuel</label>
<input
id="current_password"
class="form-input"
type="password"
formControlName="current_password"
autocomplete="current-password"
/>
<input hidden type="email" autocomplete="username" [value]="email" readonly />
@if (asksCurrentPassword()) {
<label class="form-label" for="current_password">Mot de passe actuel</label>
<input
id="current_password"
class="form-input"
type="password"
formControlName="current_password"
autocomplete="current-password"
/>
}
<label class="form-label" for="new_password">Nouveau mot de passe</label>
<input
@@ -24,7 +28,7 @@
formControlName="new_password"
autocomplete="new-password"
/>
<span class="form-hint">{{ passwordHint }}</span>
<app-password-requirements [password]="newPassword()" />
@if (errorMessage()) {
<ev-alert severity="danger">{{ errorMessage() }}</ev-alert>
@@ -1,17 +1,29 @@
import { TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http';
import { signal } from '@angular/core';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { ChangePassword } from './change-password';
import { AuthService } from '../../../core/services/auth.service';
const NOUVEAU = 'Un-nouveau-mot-de-passe1!';
describe('ChangePassword', () => {
let authMock: { changePassword: ReturnType<typeof vi.fn> };
let authMock: {
changePassword: ReturnType<typeof vi.fn>;
takeProvisionalPassword: ReturnType<typeof vi.fn>;
principal: ReturnType<typeof signal>;
};
let routerMock: { navigate: ReturnType<typeof vi.fn> };
beforeEach(async () => {
authMock = { changePassword: vi.fn() };
authMock = {
changePassword: vi.fn(),
takeProvisionalPassword: vi.fn().mockReturnValue(null),
principal: signal({ email: 'johan@enervision.fr' }),
};
routerMock = { navigate: vi.fn() };
await TestBed.configureTestingModule({
@@ -23,6 +35,10 @@ describe('ChangePassword', () => {
}).compileComponents();
});
function champActuel(fixture: { nativeElement: HTMLElement }): HTMLInputElement | null {
return fixture.nativeElement.querySelector('#current_password');
}
it('ne soumet pas si le formulaire est invalide (mot de passe trop court)', () => {
const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance;
@@ -44,7 +60,7 @@ describe('ChangePassword', () => {
it('redirige vers /dashboard après un changement réussi', () => {
const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance;
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' });
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: NOUVEAU });
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
@@ -52,46 +68,94 @@ describe('ChangePassword', () => {
expect(routerMock.navigate).toHaveBeenCalledWith(['/dashboard']);
});
it("affiche un message d'erreur si le mot de passe actuel est incorrect", () => {
const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance;
component.form.setValue({ current_password: 'mauvais-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' });
it("demande le mot de passe actuel quand la connexion ne l'a pas transmis (page rechargée)", () => {
const fixture = TestBed.createComponent(ChangePassword);
fixture.detectChanges();
authMock.changePassword.mockReturnValue(throwError(() => new Error('401')));
expect(champActuel(fixture)).not.toBeNull();
});
component.onSubmit();
fixture.detectChanges(); // rend le bloc @if (errorMessage())
it('réutilise le mot de passe provisoire de la connexion sans le redemander', () => {
authMock.takeProvisionalPassword.mockReturnValue('Provisoire-24-caracteres');
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance;
fixture.detectChanges();
expect(component.errorMessage()).toContain('incorrect');
const errorEl = fixture.nativeElement.querySelector('.ev-alert');
expect(errorEl?.textContent).toContain('incorrect');
expect(champActuel(fixture)).toBeNull();
component.form.controls.new_password.setValue(NOUVEAU);
component.onSubmit();
expect(authMock.changePassword).toHaveBeenCalledWith({
current_password: 'Provisoire-24-caracteres',
new_password: NOUVEAU,
});
});
it('associe le formulaire au compte connecté pour les gestionnaires de mots de passe', () => {
const fixture = TestBed.createComponent(ChangePassword);
fixture.detectChanges();
const identifiant = fixture.nativeElement.querySelector('input[autocomplete="username"]');
expect(identifiant.value).toBe('johan@enervision.fr');
});
it('sur un 401, dit que le mot de passe actuel est faux et le redemande', () => {
authMock.takeProvisionalPassword.mockReturnValue('Provisoire-perime');
authMock.changePassword.mockReturnValue(
throwError(() => new HttpErrorResponse({ status: 401 })),
);
const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance;
component.form.controls.new_password.setValue(NOUVEAU);
component.onSubmit();
fixture.detectChanges();
expect(component.errorMessage()).toContain('Mot de passe actuel incorrect');
expect(fixture.nativeElement.querySelector('.ev-alert')?.textContent).toContain('incorrect');
expect(champActuel(fixture)).not.toBeNull();
expect(component.form.controls.current_password.value).toBe('');
});
it('sur un 422, dit que le nouveau mot de passe ne respecte pas la politique', () => {
authMock.changePassword.mockReturnValue(
throwError(() => new HttpErrorResponse({ status: 422 })),
);
const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance;
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: NOUVEAU });
component.onSubmit();
expect(component.errorMessage()).toContain('Nouveau mot de passe refusé');
expect(component.form.controls.current_password.value).toBe('ancien-mot-de-passe');
});
it('désactive le bouton tant que le formulaire est invalide', () => {
const fixture = TestBed.createComponent(ChangePassword);
fixture.detectChanges();
const fixture = TestBed.createComponent(ChangePassword);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button[type="submit"]');
expect(button.disabled).toBe(true);
expect(fixture.nativeElement.querySelector('.ev-alert')).toBeNull();
const button = fixture.nativeElement.querySelector('button[type="submit"]');
expect(button.disabled).toBe(true);
expect(fixture.nativeElement.querySelector('.ev-alert')).toBeNull();
});
it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => {
const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance;
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' });
fixture.detectChanges();
const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance;
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: NOUVEAU });
fixture.detectChanges();
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
const form = fixture.nativeElement.querySelector('form');
form.dispatchEvent(new Event('submit'));
fixture.detectChanges();
const form = fixture.nativeElement.querySelector('form');
form.dispatchEvent(new Event('submit'));
fixture.detectChanges();
expect(authMock.changePassword).toHaveBeenCalledWith({
current_password: 'ancien-mot-de-passe',
new_password: 'Un-nouveau-mot-de-passe1!',
expect(authMock.changePassword).toHaveBeenCalledWith({
current_password: 'ancien-mot-de-passe',
new_password: NOUVEAU,
});
});
});
});
@@ -1,17 +1,20 @@
import { Component, inject, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http';
import { AuthService } from '../../../core/services/auth.service';
import { Button } from '../../../shared/components/ui/button/button';
import { Card } from '../../../shared/components/ui/card/card';
import { Alert } from '../../../shared/components/ui/alert/alert';
import { Brand } from '../../../shared/components/ui/brand/brand';
import { PasswordRequirementsChecklist } from '../../../shared/components/password-requirements/password-requirements';
import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator';
@Component({
selector: 'app-change-password',
standalone: true,
imports: [ReactiveFormsModule, Button, Card, Alert, Brand],
imports: [ReactiveFormsModule, Button, Card, Alert, Brand, PasswordRequirementsChecklist],
templateUrl: './change-password.html',
styleUrl: './change-password.scss',
})
@@ -20,30 +23,47 @@ export class ChangePassword {
private auth = inject(AuthService);
private router = inject(Router);
private provisionalPassword = this.auth.takeProvisionalPassword();
errorMessage = signal<string | null>(null);
isLoading = signal(false);
passwordHint = PASSWORD_HINT;
asksCurrentPassword = signal(this.provisionalPassword === null);
email = this.auth.principal()?.email ?? '';
form = this.fb.nonNullable.group({
current_password: ['', Validators.required],
current_password: [this.provisionalPassword ?? '', Validators.required],
new_password: ['', passwordValidators],
});
newPassword = toSignal(this.form.controls.new_password.valueChanges, { initialValue: '' });
onSubmit(): void {
if (this.form.invalid) return;
this.isLoading.set(true);
this.errorMessage.set(null);
this.auth.changePassword(this.form.getRawValue()).subscribe({
next: (response) => {
next: () => {
this.router.navigate(['/dashboard']);
},
error: () => {
error: (error: HttpErrorResponse) => {
this.isLoading.set(false);
this.errorMessage.set(
`Mot de passe actuel incorrect, ou nouveau mot de passe invalide (${this.passwordHint}).`,
);
this.errorMessage.set(this.explique(error));
if (error.status === 401) {
this.form.controls.current_password.reset('');
this.asksCurrentPassword.set(true);
}
},
});
}
private explique(error: HttpErrorResponse): string {
if (error.status === 401) {
return 'Mot de passe actuel incorrect : saisissez le mot de passe provisoire qui vous a été transmis.';
}
if (error.status === 422) {
return `Nouveau mot de passe refusé (${PASSWORD_HINT}).`;
}
return 'Le changement de mot de passe a échoué, réessayez dans un instant.';
}
}
+2
View File
@@ -58,6 +58,8 @@ services:
ports:
- "${PROXY_HTTP_PORT:-80}:80"
- "${PROXY_HTTPS_PORT:-443}:443"
# Vide : port aléatoire sur la boucle locale, pour que deux stacks sans frontal cohabitent.
- "${PROXY_FRONT_PORT:-127.0.0.1:}:4443"
volumes:
- ./infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro
- ./infra/proxy/conf.d:/etc/nginx/conf.d:ro
@@ -0,0 +1,92 @@
# 0018 - Noms publics, certificats Let's Encrypt par DNS-01 et frontal SNI sans port
- Statut : accepté
- Date : 2026-09-23
## Contexte
Les trois environnements de la VM ENI ([ADR 0009](0009-deux-environnements-compose-sur-la-vm-eni.md),
[ADR 0017](0017-environnement-dev-a-la-demande.md)) répondaient sur `enervision.local`,
`rec.enervision.local:8443` et `dev.enervision.local:9443`, avec des certificats auto-signés.
Chaque poste devait éditer son `/etc/hosts` et accepter trois avertissements du navigateur :
rien de présentable à un jury, et rien d'utilisable par quelqu'un qui n'a pas la main sur son
poste.
Contraintes : la VM n'a qu'une IP privée, `10.101.200.37`, que ni Internet ni Let's Encrypt ne
joignent, et le réseau de l'école ne doit pas être touché. Vérifications faites le 23/09 : les
résolveurs de l'école rendent bien une adresse privée pour un nom public, la VM sort en HTTPS
vers Let's Encrypt et vers l'API de dynv6, mais le filtrage de l'école bloque duckdns.org, site
et API, depuis les postes comme depuis la VM.
## Décision
**Des noms publics qui visent l'IP privée.** Dans `enervision-g3.dynv6.net`, zone gratuite de
dynv6, trois enregistrements A portent `prod.`, `rec.` et `dev.`. `provision-host.sh` les publie
par l'API dynv6 : le DNS est décrit par le code comme le reste. La prod n'est pas à la racine de
la zone : dynv6 y sert mal un TXT `_acme-challenge`, que l'API ne liste ni ne supprime et qu'un
seul de ses trois serveurs renvoie (constaté le 23/09), si bien que son défi DNS-01 échoue. Tout
poste du réseau de l'école les résout sans configuration ; hors de ce réseau, l'IP ne mène
nulle part.
**Des certificats Let's Encrypt par défi DNS-01.** Le défi passe par l'API dynv6, qui pose
l'enregistrement TXT : Let's Encrypt n'a jamais à joindre la VM. `make tls-dns01` (acme.sh
épinglé) le joue dans chaque stack ; il ne renouvelle qu'à échéance, d'où son rejeu à chaque
déploiement et chaque nuit par cron. `--dnssleep 90` laisse aux trois serveurs de dynv6 le temps
de servir le TXT avant que Let's Encrypt ne le cherche depuis plusieurs réseaux. Un certificat par environnement plutôt qu'un joker : chaque
stack garde le sien, et la clé de la prod n'est pas lisible depuis le clone de dev.
**Un frontal SNI sur 443, le seul composant exposé.** `infra/front`, un nginx sur le réseau de
l'hôte, lit le nom demandé dans le ClientHello et relaie le flux TLS intact vers la stack visée,
publiée sur la boucle locale. Il ne détient aucun certificat. Le port 80 y redirige vers
HTTPS. Les URL perdent leur port.
**Le PROXY protocol entre frontal et stacks.** Relayé tel quel, le flux arriverait avec l'IP du
frontal : `limit_req` et `get_client_ip()` compteraient tous les postes comme un seul, et un
utilisateur bloquerait la connexion de tous. Chaque proxy de stack reçoit donc le frontal sur un
écouteur dédié, 4443, qui exige l'en-tête PROXY protocol et en tire l'IP du client. Le 443 de
la stack reste sans PROXY protocol, pour les postes de développement et la sonde du déploiement.
**Le fournisseur est un paramètre.** `DNS01_API` et `DNS01_JETON_VAR` nomment le greffon acme.sh,
le jeton vit dans `dns.token` quel que soit le fournisseur : passer à un domaine acheté chez
Cloudflare ou OVH ne demande que ces deux variables et `domaine`, plus `publier_dns()`.
**`scripts/provision-host.sh` fait foi pour l'adressage et les secrets.** Un `.env` existant
garde ses secrets, reçoit ceux qui lui manquent et voit hôte, ports et profils réalignés sur le
tableau du script. C'est ce qui permet de migrer trois `.env` nés avant ce changement, et le
clone de la prod, en retard sur `main`, sans dépendre de son `.env.example`.
## Alternatives écartées
- **Garder `/etc/hosts` et l'auto-signé** : trois manipulations par poste et trois
avertissements, précisément ce qu'il fallait supprimer.
- **DuckDNS** : premier choix, inscription en un clic, mais bloqué par le filtrage de l'école :
sans son API, pas de défi DNS-01.
- **deSEC (`dedyn.io`)** : joignable et associatif, mais les inscriptions de nouveaux domaines
`dedyn.io` étaient fermées le 23/09 ; il reste le bon choix pour un domaine acheté.
- **nip.io ou sslip.io** : résolution sans compte, mais aucun moyen d'y obtenir un certificat.
- **Services à certificat joker public (traefik.me, local-ip.co)** : leur clé privée est publiée
par conception, n'importe qui peut usurper ces noms.
- **Tunnel vers Internet (Cloudflare Tunnel, Tailscale Funnel)** : accès depuis l'extérieur,
mais l'application serait exposée hors de l'école, décision refusée.
- **Autorité de certification interne (mkcert, step-ca)** : chaque poste devrait l'installer.
- **Terminaison TLS au frontal** : un seul endroit pour les certificats, mais les stacks
recevraient du HTTP clair que leur proxy redirige vers HTTPS, et en-têtes de sécurité comme
limitation de débit seraient à déplacer. Le relais SNI ne touche à rien de tout cela.
- **Domaine acheté** : plus présentable, mais un achat et un compte de plus pour un bénéfice nul
sur l'accès. Seule `DOMAINE` changerait.
## Conséquences
- L'objection de l'ADR 0009 à un proxy frontal, qui aurait dû joindre plusieurs réseaux Compose
aux services homonymes, tombe : le frontal ne joint que des ports de la boucle locale.
- Sans le frontal, plus rien n'est joignable sur la VM. `deploy.yml` le relance à chaque
déploiement de la prod, et son `restart: unless-stopped` le ramène après un redémarrage.
- Le jeton dynv6 vit dans `/srv/enervision/dns.token`, jamais dans git, GitHub ni le state
Terraform ; acme.sh en garde une copie dans `infra/proxy/acme/`, retirée à la lecture des
autres comptes. Qui le détient peut repointer les trois noms.
- dynv6 devient une dépendance : s'il tombe, les noms cessent de résoudre et les
renouvellements échouent. Les certificats valent 90 jours, la marge est large.
- Un filtrage de l'école qui viendrait à bloquer dynv6 arrêterait les renouvellements, pas les
noms : la résolution passe par les serveurs DNS de l'école, pas par le site.
- Les noms sont publics mais ne mènent qu'à une IP privée : ils révèlent l'existence de la VM,
pas son contenu.
+16 -9
View File
@@ -89,7 +89,7 @@ l'[ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md).
| `ml_score` | `0 * * * *` | `enervision_ml.score`, dans `/opt/ml/.venv` |
| `alertes` | `15 * * * *` | `app.detection.internal_alerts` puis `app.cli generate-recommendations`, dans `/opt/backend/.venv` |
| `historical_import` | manuelle | `app.etl.historical_import`, dans `/opt/backend/.venv` ; les fichiers de `data/raw` sont montés en lecture seule dans `/opt/data/raw` |
| `mock_api_import` | `45 * * * *` | `app.etl.mock_api_import`, dans `/opt/backend/.venv` ; importe l'heure précédant son déclenchement depuis l'API Mock |
| `mock_api_import` | `45 * * * *` | `app.etl.mock_api_import`, dans `/opt/backend/.venv` ; importe depuis l'API Mock la mesure de l'heure pile précédant son déclenchement |
| `derive` | `30 5 * * *` | `app.monitoring.drift`, dans `/opt/backend/.venv` ; quotidien parce que sa fenêtre couvre 168 h, et sans reprise parce qu'une dérive n'est pas une panne passagère |
Le DAG `historical_import` réutilise le pipeline historique existant sans dupliquer sa logique.
@@ -237,7 +237,8 @@ Deux conséquences se propagent jusqu'à l'application, et elles ne se devinent
Statut : `En cours`. Décision et motifs dans
l'[ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md), étendue à un troisième
environnement par l'[ADR 0017](../adr/0017-environnement-dev-a-la-demande.md).
environnement par l'[ADR 0017](../adr/0017-environnement-dev-a-la-demande.md) ; noms,
certificats et frontal sans port dans l'[ADR 0018](../adr/0018-noms-publics-certificats-dns01-et-frontal-sni.md).
La VM `eadl-2025-nantes-g3` porte le développement, la recette et la production, chacun dans son
clone du dépôt, son `.env` et son projet Compose. Le nom de projet préfixe volumes, réseau et
conteneurs : rien n'est partagé. `scripts/provision-host.sh` prépare les trois dossiers, génère
@@ -247,16 +248,21 @@ les secrets et les certificats, et ne démarre rien.
|---|---|---|---|
| Branche, environnement GitHub | toute branche lancée à la main, `dev` | `dev`, `rec` | `main`, `prod` |
| Dossier, projet Compose | `/srv/enervision/dev`, `enervision-dev` | `/srv/enervision/rec`, `enervision-rec` | `/srv/enervision/prod`, `enervision-prod` |
| URL | `https://dev.enervision.local:9443` | `https://rec.enervision.local:8443` | `https://enervision.local` |
| Proxy HTTP, HTTPS | `127.0.0.1:8083`, `9443` | `127.0.0.1:8081`, `8443` | `80`, `443` |
| URL | `https://dev.enervision-g3.dynv6.net` | `https://rec.enervision-g3.dynv6.net` | `https://prod.enervision-g3.dynv6.net` |
| Proxy HTTP, HTTPS, PROXY protocol, sur `127.0.0.1` | `8083`, `9443`, `9444` | `8081`, `8443`, `8444` | `10080`, `10443`, `10444` |
| PostgreSQL, Mailpit, Airflow, sur `127.0.0.1` | `5435`, `8027`, `8084` | `5434`, `8026`, `8082` | `5433`, `8025`, `8080` |
| Supervision (profil `monitoring`) | à la demande, `make monitoring-up` | à la demande, `make monitoring-up` | active, `COMPOSE_PROFILES=monitoring` |
| Grafana, Prometheus, Alertmanager, sur `127.0.0.1` | `3003`, `9092`, `9095` | `3002`, `9091`, `9094` | `3001`, `9090`, `9093` |
Les trois noms d'hôte visent la même IP, à déclarer dans le `/etc/hosts` des postes. Deux noms
distincts sont nécessaires : le cookie `__Secure-ev_refresh` est posé par hôte, pas par port.
La redirection HTTP de la recette et du développement est ramenée sur la boucle locale parce que la configuration
Nginx renvoie vers `https://$host` sans port, c'est-à-dire vers la production.
Les trois noms sont publics chez dynv6 et visent l'IP privée de la VM : rien à déclarer sur
les postes du réseau de l'école, et rien n'est joignable hors de ce réseau. Trois noms distincts
sont nécessaires : le cookie `__Secure-ev_refresh` est posé par hôte, pas par port.
Aucune stack ne publie hors de la boucle locale. Le frontal `infra/front`, sur le réseau de
l'hôte, écoute 80 et 443 : il redirige le premier, et aiguille le second d'après le nom demandé
(SNI) vers l'écouteur PROXY protocol de la stack visée, sans déchiffrer le TLS. Chaque stack
garde son certificat Let's Encrypt, obtenu par défi DNS-01 (`make tls-dns01`) et renouvelé à
chaque déploiement ainsi que chaque nuit par `/etc/cron.d/enervision-tls`.
Le déploiement est décrit dans [50-cicd.md](50-cicd.md) : un runner GitHub Actions installé sur
la VM aligne le dossier sur la branche poussée et lance `make stack-up`.
@@ -362,7 +368,8 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de
| API | `8000` | Identique en conteneur et hors conteneur |
| Frontend, `ng serve` | `4200` | Boucle de développement. Valeur par défaut d'`APP_CORS_ORIGINS` |
| Frontend en conteneur | `3000` | Ce qu'écoute le nginx de l'image, en conteneur comme côté hôte |
| Reverse proxy | `80` et `443` | Les seuls ports publiés par `docker-compose.prod.yml`, via `PROXY_HTTP_PORT` et `PROXY_HTTPS_PORT`. 80 ne sert que la redirection et le défi ACME. La recette publie `8443` et `127.0.0.1:8081` |
| Reverse proxy | `80` et `443`, plus `4443` | Les seuls ports publiés par `docker-compose.prod.yml`, via `PROXY_HTTP_PORT`, `PROXY_HTTPS_PORT` et `PROXY_FRONT_PORT`. 80 ne sert que la redirection et le défi ACME ; 4443 n'accepte que le PROXY protocol du frontal. Sur la VM, tous sur `127.0.0.1` |
| Frontal SNI de la VM | `80` et `443` de l'hôte | `infra/front`, seul composant exposé sur le réseau de l'école (ADR 0018) |
| 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` |
+5 -3
View File
@@ -669,13 +669,15 @@ des recommandations (`alertes`, issue #116), l'import historique (`historical_im
issue #119) et l'import périodique de l'API Mock (`mock_api_import`, issue #15).
Le DAG `mock_api_import` s'exécute chaque heure, à la minute `:45`. Il appelle
`app.etl.mock_api_import` avec un intervalle explicite d'une heure et une limite de 1 000 lectures
par site. Les deux pipelines normalisent leurs données vers les tables communes `site` et
`app.etl.mock_api_import` sur l'intervalle qui va de l'heure pile à son déclenchement, avec une
limite d'une lecture par site. L'API Mock génère autant de points que la limite demandée,
répartis sur l'intervalle et le premier à son début : un seul donne la mesure de :00, au pas
horaire du dataset historique, que les features ML supposent en décalant par ligne. Les deux pipelines normalisent leurs données vers les tables communes `site` et
`reading`, tout en conservant leur source (`csv` ou `api_history`). La réconciliation globale
des deux sources reste à compléter dans l'issue #15.
Le DAG `mock_api_import` exécute `app.etl.mock_api_import` toutes les heures. Chaque exécution
traite l'intervalle Airflow précédent. Les deux pipelines normalisent leurs données vers les
importe la mesure de l'heure pile qui précède son déclenchement. Les deux pipelines normalisent leurs données vers les
tables communes `site` et `reading`, tout en conservant leur source (`csv` ou `api_history`).
Airflow permet de planifier les traitements, gérer leur ordre d'exécution, suivre leur état et remonter les erreurs. Il ne remplace pas la logique ETL Python existante : les scripts actuels restent responsables de l'extraction, de la validation, de la transformation et du chargement. `etl/airflow/dags/ml_train.py`, `ml_score.py`, `alertes.py`, `historical_import.py` et
+5 -5
View File
@@ -1,7 +1,7 @@
"""DAG d'import périodique des données de l'API Mock EnerVision (issue #15).
Orchestre le pipeline existant `app.etl.mock_api_import` sans dupliquer sa logique ETL.
Chaque exécution traite l'heure précédant son déclenchement.
Chaque exécution importe la mesure de l'heure pile qui précède son déclenchement.
Le pipeline backend reste responsable de la validation, de la normalisation, du suivi de la
qualité, de l'idempotence et du chargement dans PostgreSQL/TimescaleDB.
@@ -18,9 +18,9 @@ from airflow.timetables.trigger import CronTriggerTimetable
# Le backend possède son propre environnement uv dans l'image Airflow (ADR 0008).
COMMANDE_BACKEND = "cd /opt/backend && env -u VIRTUAL_ENV uv run --no-sync python -m"
# Le pipeline backend et l'API acceptent au maximum 1 000 lectures par site.
# Cette marge évite de perdre silencieusement une lecture si une heure en contient plus de 60.
LIMITE_LECTURES = 1000
# Contrainte : l'API Mock génère `limit` points répartis sur l'intervalle, le premier à son début.
# Un seul, depuis l'heure pile, donne la mesure de :00 au pas du CSV que suppose `shift(168)`.
LIMITE_LECTURES = 1
# Deux reprises donnent trois tentatives au total. Même dans le pire cas, l'exécution reste
# inférieure au pas horaire du DAG.
@@ -51,7 +51,7 @@ with DAG(
task_id="import_mock_api",
bash_command=(
f"{COMMANDE_BACKEND} app.etl.mock_api_import "
"--start-time \"{{ data_interval_start.strftime('%Y-%m-%dT%H:%M:%S') }}\" "
"--start-time \"{{ data_interval_end.strftime('%Y-%m-%dT%H:00:00') }}\" "
"--end-time \"{{ data_interval_end.strftime('%Y-%m-%dT%H:%M:%S') }}\" "
f"--limit {LIMITE_LECTURES}"
),
+3 -3
View File
@@ -113,12 +113,12 @@ def test_mock_api_import_calls_the_existing_backend_module(dagbag: DagBag) -> No
assert "app.etl.mock_api_import" in commande
def test_mock_api_import_uses_the_airflow_data_interval(dagbag: DagBag) -> None:
def test_mock_api_import_asks_for_the_on_the_hour_reading(dagbag: DagBag) -> None:
commande = dagbag.dags["mock_api_import"].get_task("import_mock_api").bash_command
assert "--start-time \"{{ data_interval_start.strftime('%Y-%m-%dT%H:%M:%S') }}\"" in commande
assert "--start-time \"{{ data_interval_end.strftime('%Y-%m-%dT%H:00:00') }}\"" in commande
assert "--end-time \"{{ data_interval_end.strftime('%Y-%m-%dT%H:%M:%S') }}\"" in commande
assert "--limit 1000" in commande
assert commande.endswith("--limit 1")
@pytest.mark.parametrize("task_id", ["detection", "recommandations"])
+6
View File
@@ -38,6 +38,12 @@ Apres l'apply, la machine porte `/srv/enervision/dev`, `/srv/enervision/rec` et
manuel, `make stack-up` dans chaque dossier ; les suivants sont joues par le runner a chaque push
sur `dev` et sur `main`, et a chaque lancement manuel d'une autre branche pour `dev`.
Noms et certificats (ADR 0018) : avant l'apply, la zone `domaine` doit exister chez dynv6 et son
jeton se trouver dans `<racine>/dns.token` (600, proprietaire). L'apply fait alors pointer la
zone, `prod`, `rec` et `dev` vers la machine, obtient un certificat Let's Encrypt par environnement et planifie leur
renouvellement ; sans jeton, chaque environnement garde un certificat auto-signe. Le frontal SNI (`infra/front`)
se demarre une fois depuis le dossier de la prod, `make front-up`.
Retirer le runner se fait a la main, depuis les parametres du depot : `terraform destroy` ne le
desinscrit pas.
+12
View File
@@ -0,0 +1,12 @@
# Pourquoi : le réseau de l'hôte, parce que les trois stacks publient leur écouteur PROXY protocol
# sur 127.0.0.1 et que seul un conteneur sur l'hôte joint cette boucle locale (ADR 0018).
name: enervision-front
services:
front:
image: nginx:1.31-alpine
network_mode: host
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
restart: unless-stopped
+46
View File
@@ -0,0 +1,46 @@
# Pourquoi : trois environnements sur une seule IP, des URL sans port (ADR 0018). Ce frontal lit
# le nom demandé dans le ClientHello (SNI) et relaie le flux TLS intact vers le proxy de la
# stack visée : il ne détient aucun certificat, chaque stack garde le sien et ses en-têtes.
# Piège : relayé tel quel, le flux arriverait avec l'IP du frontal, et les limitations de débit
# de nginx et du backend deviendraient globales. D'où `proxy_protocol on`, reçu sur l'écouteur
# 4443 de chaque stack (infra/proxy/conf.d/enervision.conf), qui y restaure l'IP du client.
# Contrainte : ces ports sont ceux que `scripts/provision-host.sh` donne à PROXY_FRONT_PORT.
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
stream {
log_format aiguillage '$remote_addr [$time_local] $ssl_preread_server_name '
'-> $upstream_addr $status $session_time';
access_log /var/log/nginx/access.log aiguillage;
map $ssl_preread_server_name $stack {
~^rec\. 127.0.0.1:8444;
~^dev\. 127.0.0.1:9444;
default 127.0.0.1:10444;
}
server {
listen 443;
ssl_preread on;
proxy_pass $stack;
proxy_protocol on;
proxy_connect_timeout 5s;
}
}
http {
server_tokens off;
access_log off;
server {
listen 80 default_server;
server_name _;
return 301 https://$host$request_uri;
}
}
+22 -2
View File
@@ -76,8 +76,28 @@ Renouvellement, à passer en tâche planifiée sur la machine :
17 3 * * * cd /srv/enervision && make tls-renew >> /var/log/enervision-tls.log 2>&1
```
Pour un domaine sans port 80 entrant, le défi DNS-01 est l'alternative : elle demande un
greffon certbot propre au fournisseur DNS et un jeton d'API, hors périmètre à ce jour.
### Let's Encrypt par DNS-01, le mode de la VM
La VM n'a qu'une IP privée : le défi HTTP-01 y est impossible. Ses trois noms sont chez dynv6,
dont l'API pose l'enregistrement TXT du défi DNS-01, et acme.sh le fait sans rien ouvrir
([ADR 0018](../../docs/adr/0018-noms-publics-certificats-dns01-et-frontal-sni.md)).
```bash
make tls-dns01 # PUBLIC_HOST lu dans .env, jeton dans ../dns.token (600)
```
Autre fournisseur : `DNS01_API` et `DNS01_JETON_VAR` nomment le greffon acme.sh et sa variable
(`dns_cf` et `CF_Token` pour Cloudflare, par exemple). La cible est rejouable : acme.sh ne renouvelle qu'à trente jours de l'échéance, installe le
résultat dans `tls/` et recharge le proxy s'il tourne. Son état vit dans `acme/`, ignoré par git.
`deploy.yml` la rejoue avant chaque `make stack-up`, et `/etc/cron.d/enervision-tls` chaque nuit.
## Écouteur PROXY protocol
Sur la VM, le frontal `infra/front` relaie les connexions TLS sans les déchiffrer. Reçues sur
443, elles porteraient son adresse, et `limit_req` comme `get_client_ip()` compteraient tous les
postes comme un seul. Le port 4443 ne les accepte qu'avec l'en-tête PROXY protocol, d'où
`real_ip_header proxy_protocol` tire l'IP du client ; seules les adresses des réseaux Docker ont
le droit de l'annoncer, et le port n'est publié que sur `127.0.0.1` (`PROXY_FRONT_PORT`).
## Vérifier la configuration sans démarrer la stack
+7
View File
@@ -7,6 +7,8 @@
# variable et le résolveur interne de Docker : la résolution redevient dynamique.
# Pourquoi : la redirection 80 vers 443 conserve `$host` plutôt qu'un nom canonique, faute de
# quoi l'accès par IP cesserait de fonctionner sur la cible. Risque acté dans l'ADR 0007.
# Piège : 4443 n'accepte que le PROXY protocol du frontal (infra/front, ADR 0018), qui y porte
# l'IP du client. Seules les adresses des réseaux Docker ont le droit de l'annoncer.
server {
listen 80 default_server;
@@ -23,9 +25,14 @@ server {
server {
listen 443 ssl default_server;
listen 4443 ssl proxy_protocol default_server;
http2 on;
server_name _;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
real_ip_header proxy_protocol;
resolver 127.0.0.11 valid=10s ipv6=off;
ssl_certificate /etc/nginx/tls/fullchain.pem;
+5 -3
View File
@@ -57,9 +57,10 @@ resource "null_resource" "environnements" {
depends_on = [null_resource.docker_engine]
triggers = {
script = filesha256(local.provisionneur)
racine = var.racine
depot = var.depot_url
script = filesha256(local.provisionneur)
racine = var.racine
depot = var.depot_url
domaine = var.domaine
}
connection {
@@ -83,6 +84,7 @@ resource "null_resource" "environnements" {
${local.sudo}env RACINE='${var.racine}' \
REPO_URL='${var.depot_url}' \
PROPRIETAIRE='${var.proprietaire}' \
DOMAINE='${var.domaine}' \
PUBLIC_IP='${var.adresse_publique}' \
bash /tmp/provision-host.sh
rm -f /tmp/provision-host.sh
@@ -43,6 +43,12 @@ variable "depot_url" {
default = "https://github.com/ineszang/ProjetPiscine_EnerVision.git"
}
variable "domaine" {
type = string
description = "Zone dynv6 des trois environnements, prod., rec. et dev. en sous-domaines (ADR 0018). Son jeton doit se trouver dans <racine>/dns.token sur la machine : provision-host.sh y fait pointer la zone et ses trois sous-domaines vers la machine."
default = "enervision-g3.dynv6.net"
}
variable "adresse_publique" {
type = string
description = "Adresse annoncee dans les certificats auto-signes. Vide : la premiere adresse de la VM."
+158 -59
View File
@@ -1,25 +1,39 @@
#!/usr/bin/env bash
# Pourquoi : la machine porte trois environnements, chacun un clone du dépôt, un `.env` et un
# projet Compose (ADR 0009, ADR 0017). Ce script prépare la machine et les trois dossiers sans
# rien démarrer : construction des images et démarrage restent à l'opérateur, puis au runner.
# projet Compose (ADR 0009, 0017), derrière un frontal SNI sur 443 (ADR 0018). Ce script prépare
# la machine et les trois dossiers sans démarrer aucune stack : c'est le rôle du runner.
# Piège : le script, et non le `.env.example` du clone, fait foi pour les secrets (GENERATEURS)
# et l'adressage (tableau du bas). Un `.env` existant garde ses secrets, reçoit ceux qui lui
# manquent et voit son adressage réaligné : sans ça, un `.env` né avant une clé ne la reçoit
# jamais, et le clone de la prod, en retard sur `main`, ne connaîtrait pas les nouvelles.
# Piège : lancé en root, git refuse un clone déjà chowné au runner (propriété douteuse). D'où
# `safe.directory` passé en ligne de commande, seule portée où git l'accepte - preparer().
# Rejouable : un dossier déjà cloné est réaligné sur sa branche, un `.env` existant n'est jamais
# réécrit, un certificat présent n'est jamais régénéré.
# Rejouable : un certificat n'est refait que s'il ne couvre plus l'hôte, et celui de Let's Encrypt
# n'est renouvelé qu'à échéance.
set -euo pipefail
DEPOT="${REPO_URL:-https://github.com/ineszang/ProjetPiscine_EnerVision.git}"
RACINE="${RACINE:-/srv/enervision}"
DOMAINE="${DOMAINE:-enervision-g3.dynv6.net}"
ADRESSE="${PUBLIC_IP:-$(hostname -I | awk '{print $1}')}"
PROPRIETAIRE="${PROPRIETAIRE:-${SUDO_USER:-}}"
JETON_DNS="$RACINE/dns.token"
COMPOSE_MINIMALE="2.24.4"
erreur() { echo "erreur : $*" >&2; exit 1; }
secret() { openssl rand -base64 48 | tr -d '/+=\n' | cut -c1-48; }
court() { secret | cut -c1-20; }
# Clé Fernet : 32 octets en base64 urlsafe, padding compris.
fernet() { openssl rand -base64 32 | tr '+/' '-_'; }
declare -A GENERATEURS=(
[POSTGRES_PASSWORD]=secret [APP_SECRET_KEY]=secret [AIRFLOW_FERNET_KEY]=fernet
[AIRFLOW_API_SECRET_KEY]=secret [AIRFLOW_JWT_SECRET]=secret [AIRFLOW_ADMIN_PASSWORD]=court
[AIRFLOW_APP_SECRET_KEY]=secret [APP_METRICS_TOKEN]=secret [GRAFANA_ADMIN_PASSWORD]=court
[SUPERVISION_DB_PASSWORD]=secret
)
verifier_outils() {
for outil in git make openssl curl; do
command -v "$outil" >/dev/null || erreur "$outil absent (apt-get install $outil)"
@@ -35,11 +49,27 @@ verifier_outils() {
echo "docker compose $version, sortie Internet : ok"
}
valeur() { sed -n "s/^$2=//p" "$1" | tail -1; }
poser() {
local fichier="$1" cle="$2" contenu="$3"
if grep -q "^$cle=" "$fichier"; then
CLE="$cle" CONTENU="$contenu" awk -F= '
$1 == ENVIRON["CLE"] { print ENVIRON["CLE"] "=" ENVIRON["CONTENU"]; next } { print }
' "$fichier" > "$fichier.tmp"
mv "$fichier.tmp" "$fichier"
else
printf '%s=%s\n' "$cle" "$contenu" >> "$fichier"
fi
}
preparer() {
local env="$1" branche="$2" hote="$3" origine="$4"
local port_https="$5" port_http="$6" port_pg="$7" port_mailpit="$8" port_airflow="$9"
local profils="${10}" port_grafana="${11}" port_prometheus="${12}" port_alertmanager="${13}"
local env="$1" branche="$2" hote="$3"
local port_https="$4" port_http="$5" port_front="$6" port_pg="$7" port_mailpit="$8"
local port_airflow="$9" profils="${10}" port_grafana="${11}" port_prometheus="${12}"
local port_alertmanager="${13}"
local dossier="$RACINE/$env"
local fichier="$dossier/.env" brouillon="$dossier/.env.brouillon" cle oubliees ajoutees=""
if [[ -d "$dossier/.git" ]]; then
local git=(git -c "safe.directory=$dossier" -C "$dossier")
@@ -50,71 +80,140 @@ preparer() {
git clone --quiet --branch "$branche" "$DEPOT" "$dossier"
fi
if [[ ! -f "$dossier/.env" ]]; then
local brouillon="$dossier/.env.brouillon" oubliees
sed -e "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(secret)|" \
-e "s|^POSTGRES_PORT=.*|POSTGRES_PORT=$port_pg|" \
-e "s|^APP_SECRET_KEY=.*|APP_SECRET_KEY=$(secret)|" \
-e "s|^MAILPIT_UI_PORT=.*|MAILPIT_UI_PORT=$port_mailpit|" \
-e "s|^AIRFLOW_PORT=.*|AIRFLOW_PORT=$port_airflow|" \
-e "s|^AIRFLOW_FERNET_KEY=.*|AIRFLOW_FERNET_KEY=$(fernet)|" \
-e "s|^AIRFLOW_API_SECRET_KEY=.*|AIRFLOW_API_SECRET_KEY=$(secret)|" \
-e "s|^AIRFLOW_JWT_SECRET=.*|AIRFLOW_JWT_SECRET=$(secret)|" \
-e "s|^AIRFLOW_ADMIN_PASSWORD=.*|AIRFLOW_ADMIN_PASSWORD=$(secret | cut -c1-20)|" \
-e "s|^AIRFLOW_APP_SECRET_KEY=.*|AIRFLOW_APP_SECRET_KEY=$(secret)|" \
-e "s|^PUBLIC_HOST=.*|PUBLIC_HOST=$hote|" \
-e "s|^PUBLIC_ORIGIN=.*|PUBLIC_ORIGIN=$origine|" \
-e "s|^COMPOSE_PROJECT_NAME=.*|COMPOSE_PROJECT_NAME=enervision-$env|" \
-e "s|^PROXY_HTTP_PORT=.*|PROXY_HTTP_PORT=$port_http|" \
-e "s|^PROXY_HTTPS_PORT=.*|PROXY_HTTPS_PORT=$port_https|" \
-e "s|^COMPOSE_PROFILES=.*|COMPOSE_PROFILES=$profils|" \
-e "s|^APP_METRICS_TOKEN=.*|APP_METRICS_TOKEN=$(secret)|" \
-e "s|^GRAFANA_ADMIN_PASSWORD=.*|GRAFANA_ADMIN_PASSWORD=$(secret | cut -c1-20)|" \
-e "s|^SUPERVISION_DB_PASSWORD=.*|SUPERVISION_DB_PASSWORD=$(secret)|" \
-e "s|^GRAFANA_PORT=.*|GRAFANA_PORT=$port_grafana|" \
-e "s|^PROMETHEUS_PORT=.*|PROMETHEUS_PORT=$port_prometheus|" \
-e "s|^ALERTMANAGER_PORT=.*|ALERTMANAGER_PORT=$port_alertmanager|" \
"$dossier/.env.example" > "$brouillon"
# Branche antérieure à l'ADR 0009 : ces clés manquent alors dans .env.example.
for cle in "COMPOSE_PROJECT_NAME=enervision-$env" "PUBLIC_ORIGIN=$origine" \
"PROXY_HTTP_PORT=$port_http" "PROXY_HTTPS_PORT=$port_https"; do
grep -q "^${cle%%=*}=" "$brouillon" || echo "$cle" >> "$brouillon"
done
# Piège : une clé renommée en amont garde sa valeur d'exemple, que le `:?` du compose ne
# voit pas puisqu'elle n'est pas vide. Cas vécu : AIRFLOW_WEBSERVER_SECRET_KEY, Airflow 3.
oubliees="$(grep '=change_me$' "$brouillon" | grep -v '^APP_MOCK_API_' | cut -d= -f1 | tr '\n' ' ' || true)"
if [[ -n "$oubliees" ]]; then
rm -f "$brouillon"
erreur "$env : secrets non générés, .env non écrit : $oubliees"
fi
chmod 600 "$brouillon"
mv "$brouillon" "$dossier/.env"
echo "$env : .env généré. Reste à renseigner APP_MOCK_API_USERNAME et APP_MOCK_API_PASSWORD."
local masque
masque="$(umask)"
umask 077
if [[ -f "$fichier" ]]; then
cp -p "$fichier" "$brouillon"
awk -F= 'NR == FNR { connues[$1]; next } /^[A-Z_][A-Z0-9_]*=/ && !($1 in connues)' \
"$fichier" "$dossier/.env.example" >> "$brouillon"
else
cp "$dossier/.env.example" "$brouillon"
fi
if [[ ! -f "$dossier/infra/proxy/tls/fullchain.pem" ]]; then
(cd "$dossier" && PUBLIC_HOST="$hote" PUBLIC_IP="$ADRESSE" ./scripts/tls-selfsigned.sh)
for cle in "${!GENERATEURS[@]}"; do
case "$(valeur "$brouillon" "$cle")" in
"" | change_me) poser "$brouillon" "$cle" "$("${GENERATEURS[$cle]}")"; ajoutees+=" $cle" ;;
esac
done
declare -A adressage=(
[PUBLIC_HOST]="$hote" [PUBLIC_ORIGIN]="https://$hote" [COMPOSE_PROJECT_NAME]="enervision-$env"
[PROXY_HTTPS_PORT]="$port_https" [PROXY_HTTP_PORT]="$port_http" [PROXY_FRONT_PORT]="$port_front"
[POSTGRES_PORT]="$port_pg" [MAILPIT_UI_PORT]="$port_mailpit" [AIRFLOW_PORT]="$port_airflow"
[COMPOSE_PROFILES]="$profils" [GRAFANA_PORT]="$port_grafana"
[PROMETHEUS_PORT]="$port_prometheus" [ALERTMANAGER_PORT]="$port_alertmanager"
)
for cle in "${!adressage[@]}"; do
poser "$brouillon" "$cle" "${adressage[$cle]}"
done
# Piège : une clé renommée en amont garde sa valeur d'exemple, que le `:?` du compose ne voit
# pas puisqu'elle n'est pas vide. Cas vécu : AIRFLOW_WEBSERVER_SECRET_KEY, Airflow 3.
oubliees="$(grep '=change_me$' "$brouillon" | grep -v '^APP_MOCK_API_' | cut -d= -f1 | tr '\n' ' ' || true)"
if [[ -n "$oubliees" ]]; then
rm -f "$brouillon"
erreur "$env : clés sans générateur, .env inchangé : $oubliees"
fi
echo "$env : $dossier sur $branche, $origine"
chmod 600 "$brouillon"
if [[ -f "$fichier" ]]; then
cat "$brouillon" > "$fichier"
rm -f "$brouillon"
echo "$env : .env réaligné sur le tableau${ajoutees:+, secrets ajoutés :$ajoutees}"
else
mv "$brouillon" "$fichier"
echo "$env : .env généré. Reste à renseigner APP_MOCK_API_USERNAME et APP_MOCK_API_PASSWORD."
fi
umask "$masque"
if ! openssl x509 -in "$dossier/infra/proxy/tls/fullchain.pem" -noout -checkhost "$hote" 2>/dev/null \
| grep -q " does match"; then
(cd "$dossier" && PUBLIC_HOST="$hote" PUBLIC_IP="$ADRESSE" ./scripts/tls-selfsigned.sh --force)
fi
if [[ -r "$JETON_DNS" && "$hote" != *.local ]]; then
make -C "$dossier" --no-print-directory tls-dns01 PUBLIC_HOST="$hote" \
|| echo "$env : pas de certificat Let's Encrypt, l'auto-signé reste en place" >&2
fi
echo "$env : $dossier sur $branche, https://$hote"
}
# Pourquoi : dynv6 est le fournisseur que le filtrage de l'école laisse passer (ADR 0018). Le
# jeton passe par l'environnement du seul processus Python, jamais par `argv`.
publier_dns() {
[[ -r "$JETON_DNS" ]] || { echo "pas de jeton $JETON_DNS : ni DNS ni Let's Encrypt"; return 0; }
DNS_TOKEN="$(tr -d '[:space:]' < "$JETON_DNS")" python3 - "$DOMAINE" "$ADRESSE" prod rec dev <<'PY' \
|| echo "DNS : dynv6 refuse la mise à jour de $DOMAINE, enregistrements inchangés" >&2
import json, os, sys, time, urllib.request
domaine, adresse, *sous_noms = sys.argv[1:]
def appel(methode, chemin, corps=None):
requete = urllib.request.Request(
f"https://dynv6.com/api/v2/{chemin}", method=methode,
data=None if corps is None else json.dumps(corps).encode(),
headers={"Authorization": f"Bearer {os.environ['DNS_TOKEN']}", "User-Agent": "enervision-provision",
"Content-Type": "application/json", "Accept": "application/json"})
with urllib.request.urlopen(requete, timeout=60) as reponse:
contenu = reponse.read()
return json.loads(contenu) if contenu else None
def synchroniser():
zone = appel("GET", f"zones/by-name/{domaine}")
if zone.get("ipv4address") != adresse:
appel("PATCH", f"zones/{zone['id']}", {"ipv4address": adresse})
existants = {(r["type"], r["name"]): r for r in appel("GET", f"zones/{zone['id']}/records")}
for nom in sous_noms:
actuel = existants.get(("A", nom))
if actuel is None:
appel("POST", f"zones/{zone['id']}/records", {"type": "A", "name": nom, "data": adresse})
elif actuel["data"] != adresse:
appel("PATCH", f"zones/{zone['id']}/records/{actuel['id']}", {"data": adresse})
# dynv6 laisse parfois une écriture sans réponse, appliquée ou non : chaque essai relit l'état
# avant d'écrire, si bien qu'une création aboutie malgré le délai n'est jamais dupliquée.
for essai in range(3):
try:
synchroniser()
break
except OSError:
if essai == 2:
raise
time.sleep(5)
print(f"DNS : {domaine}, {', '.join(sous_noms)} visent {adresse}")
PY
}
planifier_renouvellement() {
[[ "$(id -u)" -eq 0 && -n "$PROPRIETAIRE" && -d /etc/cron.d ]] || return 0
cat > /etc/cron.d/enervision-tls <<CRON
# Renouvellement Let's Encrypt des trois environnements (ADR 0018), écrit par provision-host.sh.
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
23 4 * * * $PROPRIETAIRE for e in prod rec dev; do make -C $RACINE/\$e --no-print-directory tls-dns01; done 2>&1 | logger -t enervision-tls
CRON
chmod 644 /etc/cron.d/enervision-tls
echo "renouvellement planifié : /etc/cron.d/enervision-tls"
}
verifier_outils
mkdir -p "$RACINE"
publier_dns
# Supervision active en prod seulement (ADR 0016) ; les ports de la recette et de dev restent
# décalés au cas où on l'y lancerait à la demande.
# env branche hôte origine https http pg mailpit airflow profils grafana prometheus alertmanager
preparer prod main enervision.local https://enervision.local 443 80 5433 8025 8080 monitoring 3001 9090 9093
preparer rec dev rec.enervision.local https://rec.enervision.local:8443 8443 127.0.0.1:8081 5434 8026 8082 "" 3002 9091 9094
preparer dev dev dev.enervision.local https://dev.enervision.local:9443 9443 127.0.0.1:8083 5435 8027 8084 "" 3003 9092 9095
# Supervision active en prod seulement (ADR 0016). La prod vit sur `prod.` et non à la racine :
# dynv6 ne sert pas de façon fiable un TXT `_acme-challenge` à la racine de la zone (ADR 0018).
# env branche hôte https http front pg mailpit airflow profils grafana prometheus alertmanager
preparer prod main "prod.$DOMAINE" 127.0.0.1:10443 127.0.0.1:10080 127.0.0.1:10444 5433 8025 8080 monitoring 3001 9090 9093
preparer rec dev "rec.$DOMAINE" 127.0.0.1:8443 127.0.0.1:8081 127.0.0.1:8444 5434 8026 8082 "" 3002 9091 9094
preparer dev dev "dev.$DOMAINE" 127.0.0.1:9443 127.0.0.1:8083 127.0.0.1:9444 5435 8027 8084 "" 3003 9092 9095
planifier_renouvellement
if [[ -n "$PROPRIETAIRE" && "$(id -u)" -eq 0 ]]; then
chown -R "$PROPRIETAIRE" "$RACINE"
fi
cat <<FIN
Démarrage, dans chaque dossier : make stack-up, qui applique aussi les migrations.
Démarrage, dans chaque dossier : make stack-up, qui applique aussi les migrations. Puis, une
fois, depuis $RACINE/prod : make front-up, que chaque déploiement de la prod rejoue ensuite.
Premier administrateur, stack démarrée, dans chaque dossier :
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec backend \\
python -m app.cli create-admin --email <adresse>
@@ -124,6 +223,6 @@ L'installer sous le propriétaire de $RACINE, sinon git refuse ces dépôts et l
échappe : relancer au besoin ce script avec PROPRIETAIRE=<utilisateur du runner>.
Données historiques : git ne porte pas data/raw, déposer les fichiers dans chaque dossier avant
de déclencher le DAG historical_import.
Depuis un poste : ajouter « $ADRESSE enervision.local rec.enervision.local dev.enervision.local »
à /etc/hosts.
Noms et certificats : le jeton dynv6 de la zone $DOMAINE doit se trouver dans $JETON_DNS (600,
propriétaire du runner). Sans lui, ni enregistrement DNS ni Let's Encrypt : auto-signé.
FIN
+368 -7
View File
@@ -10,7 +10,7 @@
"devDependencies": {
"@playwright/test": "^1.63.0",
"@types/node": "^26.6.2",
"typescript": "~6.0.2"
"typescript": "~7.0.2"
}
},
"node_modules/@playwright/test": {
@@ -39,6 +39,346 @@
"undici-types": "~8.9.0"
}
},
"node_modules/@typescript/typescript-aix-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-loong64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-mips64el": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-riscv64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-s390x": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-sunos-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
@@ -69,17 +409,38 @@
}
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
"tsc": "bin/tsc"
},
"engines": {
"node": ">=14.17"
"node": ">=16.20.0"
},
"optionalDependencies": {
"@typescript/typescript-aix-ppc64": "7.0.2",
"@typescript/typescript-darwin-arm64": "7.0.2",
"@typescript/typescript-darwin-x64": "7.0.2",
"@typescript/typescript-freebsd-arm64": "7.0.2",
"@typescript/typescript-freebsd-x64": "7.0.2",
"@typescript/typescript-linux-arm": "7.0.2",
"@typescript/typescript-linux-arm64": "7.0.2",
"@typescript/typescript-linux-loong64": "7.0.2",
"@typescript/typescript-linux-mips64el": "7.0.2",
"@typescript/typescript-linux-ppc64": "7.0.2",
"@typescript/typescript-linux-riscv64": "7.0.2",
"@typescript/typescript-linux-s390x": "7.0.2",
"@typescript/typescript-linux-x64": "7.0.2",
"@typescript/typescript-netbsd-arm64": "7.0.2",
"@typescript/typescript-netbsd-x64": "7.0.2",
"@typescript/typescript-openbsd-arm64": "7.0.2",
"@typescript/typescript-openbsd-x64": "7.0.2",
"@typescript/typescript-sunos-x64": "7.0.2",
"@typescript/typescript-win32-arm64": "7.0.2",
"@typescript/typescript-win32-x64": "7.0.2"
}
},
"node_modules/undici-types": {
+1 -1
View File
@@ -10,6 +10,6 @@
"devDependencies": {
"@playwright/test": "^1.63.0",
"@types/node": "^26.6.2",
"typescript": "~6.0.2"
"typescript": "~7.0.2"
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ test('impose le changement du mot de passe temporaire avant le tableau de bord',
await expect(page).toHaveURL(/\/change-password$/);
await expect(page.getByRole('heading', { name: 'Nouveau mot de passe' })).toBeVisible();
await page.getByLabel('Mot de passe actuel').fill(compte.password);
await expect(page.getByLabel('Mot de passe actuel')).toHaveCount(0);
await page.getByLabel('Nouveau mot de passe').fill(nouveauMotDePasse());
await page.getByRole('button', { name: 'Valider' }).click();