Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c09beeb3c | ||
|
|
cbbfaf4910 | ||
|
|
c2f360c591 | ||
|
|
933f0a3360 | ||
|
|
dbcd5c4240 | ||
|
|
46d10209f1 | ||
|
|
db6ee6e56d | ||
|
|
84969d3375 | ||
|
|
93d5cad5af | ||
|
|
4d88604a07 | ||
|
|
22a88e193c | ||
|
|
d687d7dc58 | ||
|
|
288970df77 | ||
|
|
985c188106 | ||
|
|
b786f27a4d | ||
|
|
3e871a3e8b | ||
|
|
fe0d4222a5 |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ jobs:
|
||||
compose:
|
||||
- "docker-compose*.yml"
|
||||
- ".env.example"
|
||||
- "infra/front/**"
|
||||
- "monitoring/**"
|
||||
- ".github/workflows/infra.yml"
|
||||
workflows:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
),
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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,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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Generated
+368
-7
@@ -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": {
|
||||
|
||||
@@ -10,6 +10,6 @@
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@types/node": "^26.6.2",
|
||||
"typescript": "~6.0.2"
|
||||
"typescript": "~7.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user