Compare commits
59
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea8f9d0a3a | ||
|
|
f58fc4ba81 | ||
|
|
2c7ee2c064 | ||
|
|
e220f8f0c6 | ||
|
|
fb4235df3a | ||
|
|
357cf996ed | ||
|
|
4fb8b5a784 | ||
|
|
c1f9dec5fe | ||
|
|
d9103ee4ed | ||
|
|
fca36649f9 | ||
|
|
0db69fd023 | ||
|
|
be6be42947 | ||
|
|
3ddeb24207 | ||
|
|
b5993f994e | ||
|
|
091ee344d1 | ||
|
|
f81959c1a9 | ||
|
|
5c361c6952 | ||
|
|
a27e0c6535 | ||
|
|
7a97a4d1ac | ||
|
|
20abcfee33 | ||
|
|
3d6d2eb760 | ||
|
|
154deca511 | ||
|
|
a577356198 | ||
|
|
19ff152445 | ||
|
|
bf3584182f | ||
|
|
28e8260216 | ||
|
|
e90e563a1c | ||
|
|
d7ca9fd8b0 | ||
|
|
67204b5eb6 | ||
|
|
985466e9cc | ||
|
|
c347212c93 | ||
|
|
c67c7802c1 | ||
|
|
e824423889 | ||
|
|
436147c8cb | ||
|
|
8fb7ab8680 | ||
|
|
65baf3fddb | ||
|
|
19f4ddc62f | ||
|
|
282472bae9 | ||
|
|
8d80cdc79d | ||
|
|
b61287c8d2 | ||
|
|
a4be46c6e6 | ||
|
|
66a65991b5 | ||
|
|
095cb4559b | ||
|
|
d48b0eef4d | ||
|
|
c8e8a44390 | ||
|
|
401d5a3174 | ||
|
|
6a70443d52 | ||
|
|
6d741e45fb | ||
|
|
1bec2c1376 | ||
|
|
00fab49d80 | ||
|
|
de88c4f156 | ||
|
|
67dcf506e9 | ||
|
|
1502eea036 | ||
|
|
cf50d3d36c | ||
|
|
4afcfb3ced | ||
|
|
79b157d37e | ||
|
|
851e0279f6 | ||
|
|
7f025f72ac | ||
|
|
1cd206adc8 |
+8
-5
@@ -29,15 +29,18 @@ APP_MOCK_API_USERNAME=change_me
|
||||
APP_MOCK_API_PASSWORD=change_me
|
||||
APP_MOCK_API_TIMEOUT_SECONDS=10
|
||||
|
||||
# Airflow (webserver + scheduler, LocalExecutor). Base de métadonnées dédiée `airflow` dans le
|
||||
# Airflow (api-server + scheduler + dag-processor, LocalExecutor). Base de métadonnées dédiée `airflow` dans le
|
||||
# même conteneur `db` (cf. db/init/120-airflow-database.sql), pas un conteneur de plus.
|
||||
AIRFLOW_PORT=8080
|
||||
# Chiffre les connexions/variables stockées par Airflow. Générer la vôtre :
|
||||
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
AIRFLOW_FERNET_KEY=change_me
|
||||
# Clé Flask du webserver Airflow (signature de session), distincte de la précédente. Générer la
|
||||
# Clé de session de l'api-server Airflow, distincte de la précédente. Générer la
|
||||
# vôtre : python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
AIRFLOW_WEBSERVER_SECRET_KEY=change_me
|
||||
AIRFLOW_API_SECRET_KEY=change_me
|
||||
# Secret des jetons JWT entre scheduler, tâches et api-server (conteneurs distincts, le secret
|
||||
# doit être partagé). Même générateur que ci-dessus.
|
||||
AIRFLOW_JWT_SECRET=change_me
|
||||
AIRFLOW_ADMIN_USERNAME=admin
|
||||
# Compte Airflow créé au premier démarrage (service `airflow-init`), sans rapport avec les
|
||||
# comptes `app_user` d'EnerVision.
|
||||
@@ -67,7 +70,7 @@ PUBLIC_ORIGIN=
|
||||
PROXY_HTTP_PORT=
|
||||
PROXY_HTTPS_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, et le webserver Airflow lance 4 workers gunicorn.
|
||||
# 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.
|
||||
TS_TUNE_MEMORY=2GB
|
||||
TS_TUNE_NUM_CPUS=2
|
||||
AIRFLOW_WEBSERVER_WORKERS=2
|
||||
|
||||
@@ -10,6 +10,15 @@ updates:
|
||||
frontend-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
ignore:
|
||||
# Pourquoi : @angular/build épingle typescript (>=6.0 <6.1) et vitest (^4). Une majeure
|
||||
# de l'un ou l'autre casse `npm ci` tant qu'Angular ne suit pas.
|
||||
- dependency-name: "typescript"
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: "vitest"
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: "@vitest/coverage-v8"
|
||||
update-types: ["version-update:semver-major"]
|
||||
|
||||
# Backend — uv (lit pyproject.toml / uv.lock)
|
||||
- package-ecosystem: "uv"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
name: Airflow
|
||||
|
||||
# Piège : la version de Python vient de etl/airflow/.python-version. C'est 3.12 et non 3.14
|
||||
# (contrairement à backend.yml et ml.yml) : apache-airflow 2.10 ne supporte pas 3.14. Le 3.14 de
|
||||
# ml/ ne vit que dans l'image Docker, dans son propre environnement (cf. etl/airflow/Dockerfile).
|
||||
# (contrairement à backend.yml et ml.yml) : celui de l'image apache/airflow retenue, et les tests
|
||||
# doivent tourner sur le même interpréteur qu'elle. Le 3.14 de ml/ ne vit que dans l'image
|
||||
# Docker, dans son propre environnement (cf. etl/airflow/Dockerfile).
|
||||
#
|
||||
# Piège : l'image COPY les fichiers de dépendances et le code de ml/ et de apps/backend/. Une
|
||||
# modification de l'un ou de l'autre peut donc casser sa construction, d'où ces chemins dans
|
||||
@@ -47,10 +48,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Récupère le dépôt
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Installe uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: etl/airflow/uv.lock
|
||||
@@ -78,7 +79,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Récupère le dépôt
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Construit l'image (contexte à la racine, elle COPY ml/ et apps/backend/)
|
||||
run: docker build -f etl/airflow/Dockerfile -t enervision-airflow:ci .
|
||||
|
||||
@@ -30,10 +30,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Récupère le dépôt
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Installe uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: apps/backend/uv.lock
|
||||
@@ -90,10 +90,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Récupère le dépôt
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Installe uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: apps/backend/uv.lock
|
||||
@@ -126,10 +126,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Récupère le dépôt
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Installe uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: apps/backend/uv.lock
|
||||
@@ -150,12 +150,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Récupère le dépôt
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
# 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@v5
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
# Pourquoi : le périmètre est `app`, le code livré. Les tests emploient légitimement des
|
||||
# secrets factices et des `assert` que Bandit signalerait sans qu'aucun n'atteigne la prod.
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
name: DAST
|
||||
|
||||
# Scan dynamique OWASP ZAP de l'API (issue #41). Il attaque une API qui tourne : le job démarre
|
||||
# la base et le backend sur le runner, sème un site et quelques relevés (sans ça le scan ne
|
||||
# frappe que des gestionnaires d'erreur), crée un compte `lecteur` jetable
|
||||
# (scripts/dast-token.sh), puis lance ZAP sur le contrat OpenAPI avec le jeton de ce compte.
|
||||
#
|
||||
# Non bloquant pour l'instant sur les alertes (`continue-on-error` sur la seule étape du scan) :
|
||||
# le volume d'un premier passage trié est inconnu. Deux étapes suivantes, elles, bloquent si le
|
||||
# scan n'a rien testé (import du contrat, absence de toute réponse de succès) : un job vert doit
|
||||
# vouloir dire qu'un scan a eu lieu.
|
||||
#
|
||||
# Piège : ce scan tape la configuration par défaut du backend (`APP_ENV=local`, pas de TLS, pas
|
||||
# de reverse proxy). Il ne dit rien des en-têtes ni du TLS posés par le proxy en production, et
|
||||
# remontera des alertes (HSTS absent...) qui n'existent pas derrière lui.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# Un scan actif est long : hebdomadaire plutôt qu'à chaque PR.
|
||||
- cron: "0 3 * * 1"
|
||||
pull_request:
|
||||
# Ne se lance sur une PR que si le scan lui-même change.
|
||||
paths:
|
||||
- ".github/workflows/dast.yml"
|
||||
- "scripts/dast-token.sh"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: dast-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
zap:
|
||||
name: Scan OWASP ZAP de l'API
|
||||
runs-on: ubuntu-latest
|
||||
# Généreux face aux ~2 minutes observées de bout en bout : le vrai plafond est
|
||||
# `scanner.maxScanDurationInMins` (étape Scan ZAP), sous le TTL du jeton. Une annulation par
|
||||
# ce timeout-ci n'exécute pas les étapes `always()` : mieux vaut ne jamais l'atteindre.
|
||||
timeout-minutes: 30
|
||||
|
||||
# Même image que docker-compose.yml : la première migration refuse de s'appliquer sans
|
||||
# l'extension TimescaleDB (cf. backend.yml).
|
||||
services:
|
||||
db:
|
||||
image: timescale/timescaledb-ha:pg17
|
||||
env:
|
||||
POSTGRES_USER: enervision
|
||||
POSTGRES_PASSWORD: change_me
|
||||
POSTGRES_DB: enervision_dast
|
||||
ports:
|
||||
- "5433:5432"
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U enervision -d enervision_dast"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 12
|
||||
--health-start-period 40s
|
||||
|
||||
env:
|
||||
# Base jetable : ZAP y écrira et le script y crée deux comptes.
|
||||
DATABASE_URL: postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_dast
|
||||
APP_SECRET_KEY: secret-de-scan-assez-long-pour-le-validateur
|
||||
APP_ENV: local
|
||||
# Le jeton du lecteur doit survivre à toute la durée du scan (15 minutes par défaut).
|
||||
# 3600 est le plafond accepté par la configuration ; `scanner.maxScanDurationInMins`
|
||||
# (étape Scan ZAP) reste très en dessous, marge comprise pour les étapes qui l'entourent.
|
||||
APP_ACCESS_TOKEN_TTL_SECONDS: "3600"
|
||||
PGPASSWORD: change_me
|
||||
|
||||
steps:
|
||||
- name: Récupère le dépôt
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- 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
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: apps/backend/uv.lock
|
||||
# `prune-cache` vaut `true` par défaut (encore sur ce commit) : l'étape de post-job
|
||||
# « Pruning cache » est restée bloquée 5 minutes avant d'échouer (exit code 2) sur un
|
||||
# run où les 16 étapes précédentes passaient, sans lien avec le scan. Le prune n'est
|
||||
# qu'une optimisation de taille de cache entre deux runs, pas une garantie : le
|
||||
# désactiver retire le blocage sans rien changer au comportement du job.
|
||||
prune-cache: false
|
||||
|
||||
- name: Installe l'interpréteur déclaré par .python-version
|
||||
run: uv python install
|
||||
working-directory: apps/backend
|
||||
|
||||
# `--no-build` : aucune dépendance n'est construite depuis ses sources, donc aucun script de
|
||||
# build exécuté (règle Sonar S8541). Le projet lui-même n'est pas installé : il tourne depuis
|
||||
# `apps/backend`, comme dans son Dockerfile. Les `uv run` suivants portent `--frozen
|
||||
# --no-sync` pour ne rien résoudre ni reconstruire (règle S8544).
|
||||
- name: Synchronise les dépendances sans dévier du verrou
|
||||
run: uv sync --frozen --no-dev --no-install-project --no-build
|
||||
working-directory: apps/backend
|
||||
|
||||
- name: Active TimescaleDB sur la base du scan
|
||||
run: psql -h localhost -p 5433 -U enervision -d enervision_dast -c "CREATE EXTENSION IF NOT EXISTS timescaledb"
|
||||
|
||||
- name: Applique les migrations
|
||||
run: uv run --frozen --no-sync --no-build alembic upgrade head
|
||||
working-directory: apps/backend
|
||||
|
||||
# Sans données, `GET /sites` rend `[]`, chaque `/{site_id}` rend 404 et le scan actif ne
|
||||
# frappe que des gestionnaires d'erreur plutôt que la logique métier. `db/seeds/` est vide
|
||||
# (pas encore d'outillage de jeu de données pour la CI) : un site et deux relevés à la main,
|
||||
# juste assez pour que les routes de lecture aient quelque chose à rendre.
|
||||
- name: Insère un site et des relevés minimaux pour le scan
|
||||
run: |
|
||||
psql -h localhost -p 5433 -U enervision -d enervision_dast <<'SQL'
|
||||
INSERT INTO site (site_id, site_name, site_type, location, capacity_kw, status)
|
||||
VALUES ('dast-site', 'Site du scan DAST', 'bureau', 'CI', 50, 'actif')
|
||||
ON CONFLICT (site_id) DO NOTHING;
|
||||
|
||||
INSERT INTO reading (site_id, timestamp, source, consumption_kw, consumption_kwh, is_working_hours, data_quality, raw_data)
|
||||
VALUES
|
||||
('dast-site', now() - interval '2 hours', 'api_current', 12.5, 12.5, true, 'good', '{}'),
|
||||
('dast-site', now() - interval '1 hour', 'api_current', 13.0, 13.0, true, 'good', '{}')
|
||||
ON CONFLICT DO NOTHING;
|
||||
SQL
|
||||
|
||||
- name: Démarre l'API
|
||||
run: |
|
||||
nohup uv run --frozen --no-sync --no-build uvicorn app.main:create_app --factory \
|
||||
--host 0.0.0.0 --port 8000 > "$RUNNER_TEMP/api.log" 2>&1 &
|
||||
for _ in $(seq 1 30); do
|
||||
curl -fsS http://localhost:8000/api/v1/health/ready >/dev/null 2>&1 && exit 0
|
||||
sleep 2
|
||||
done
|
||||
echo "L'API ne répond pas sur /health/ready" >&2
|
||||
cat "$RUNNER_TEMP/api.log" >&2
|
||||
exit 1
|
||||
working-directory: apps/backend
|
||||
|
||||
- name: Crée le compte lecteur du scan
|
||||
id: jeton
|
||||
run: |
|
||||
jeton="$(../../scripts/dast-token.sh)"
|
||||
echo "::add-mask::$jeton"
|
||||
echo "jeton=$jeton" >> "$GITHUB_OUTPUT"
|
||||
working-directory: apps/backend
|
||||
|
||||
# Étape distincte du scan lui-même, et sans `continue-on-error` : un `curl` qui échoue ici
|
||||
# (API tombée juste après la sonde de readiness, par exemple) doit rester un échec visible,
|
||||
# pas se travestir en « ZAP n'a importé aucune URL » à l'étape de garde suivante.
|
||||
- name: Prépare le contrat pour ZAP
|
||||
run: |
|
||||
mkdir -p zap-out zap-logs
|
||||
curl -fsS http://localhost:8000/openapi.json -o zap-out/openapi.json
|
||||
# Le dossier passe à l'uid 1000 (utilisateur du conteneur ZAP) : le runner n'y écrit
|
||||
# plus après ce chown, d'où `zap-logs/` (uid du runner) pour les journaux ci-dessous.
|
||||
# Pas de `chmod 777` (règle Sonar S2612).
|
||||
sudo chown -R 1000:1000 zap-out
|
||||
|
||||
# `--network host` : ZAP atteint l'API sur le localhost du runner.
|
||||
#
|
||||
# Piège vécu : la clé du nom d'en-tête est `matchstr`, pas `matchstring`. ZAP accepte
|
||||
# n'importe quelle clé `-config` sans erreur ; avec la mauvaise, il ajoutait à TOUTES les
|
||||
# requêtes un en-tête au nom vide (`: Bearer <jeton>`), qu'uvicorn refuse par un 400
|
||||
# (« Invalid HTTP request received »), y compris sur les routes publiques.
|
||||
#
|
||||
# Le jeton ne passe ni par `${{ }}` dans ce script (il finirait en clair dans le fichier de
|
||||
# commande que GitHub écrit sur le disque du runner pour toute la durée de l'étape), ni par
|
||||
# l'argv de `docker run` (visible par `ps aux` et par `docker inspect zap` tant que le
|
||||
# conteneur existe) : il est écrit dans un fichier de configuration ZAP séparé, monté en
|
||||
# lecture seule hors de `/zap/wrk` pour ne jamais atterrir dans l'artefact publié.
|
||||
#
|
||||
# Les routes d'authentification qui changent l'état du compte du scan sont exclues : un
|
||||
# scan actif y déclencherait la limitation de débit du login, la réinitialisation de mots de
|
||||
# passe et la fermeture des sessions, sans rien apprendre de plus.
|
||||
#
|
||||
# `scanner.maxScanDurationInMins`/`maxRuleDurationInMins` bornent le scan actif, que `-T` ne
|
||||
# couvre pas (il ne borne que le démarrage et le scan passif) : sans ça, une règle qui
|
||||
# traîne peut dépasser le TTL du jeton (401 muets en fin de scan) ou le timeout du job (qui
|
||||
# annule sans exécuter les étapes `always()`, rapport et journaux perdus).
|
||||
- name: Scan ZAP
|
||||
id: zap
|
||||
continue-on-error: true
|
||||
env:
|
||||
JETON: ${{ steps.jeton.outputs.jeton }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
printf 'replacer.full_list(0).description=auth\nreplacer.full_list(0).enabled=true\nreplacer.full_list(0).matchtype=REQ_HEADER\nreplacer.full_list(0).matchstr=Authorization\nreplacer.full_list(0).regex=false\nreplacer.full_list(0).replacement=Bearer %s\n' "$JETON" > "$RUNNER_TEMP/zap-auth.conf"
|
||||
# Piège vécu : `chmod 600` seul rend le fichier illisible pour le conteneur, qui lit un
|
||||
# montage bind avec son propre uid (1000), distinct de celui du runner qui l'a écrit.
|
||||
# ZAP échoue alors dès le lancement (« File not readable: /zap/auth.conf »), et
|
||||
# `zap-api-scan.py` attend `-T` minutes complètes avant d'abandonner : dix minutes qui
|
||||
# ressemblent à un scan actif, pour un daemon mort depuis le début.
|
||||
#
|
||||
# Piège vécu (numéro deux) : une fois le fichier passé à l'uid 1000 par `sudo chown`,
|
||||
# l'utilisateur du runner n'en est plus propriétaire et un `chmod` sans `sudo` échoue
|
||||
# (« Operation not permitted »). Avec le `-e` implicite de bash sur les étapes GitHub
|
||||
# Actions, cette erreur arrêtait toute l'étape avant même `docker run` : scan « réussi »
|
||||
# en une fraction de seconde, sans le moindre journal ni rapport produit.
|
||||
sudo chown 1000:1000 "$RUNNER_TEMP/zap-auth.conf"
|
||||
sudo chmod 644 "$RUNNER_TEMP/zap-auth.conf"
|
||||
docker run --name zap --network host \
|
||||
-v "$PWD/zap-out:/zap/wrk:rw" \
|
||||
-v "$RUNNER_TEMP/zap-auth.conf:/zap/auth.conf:ro" \
|
||||
ghcr.io/zaproxy/zaproxy:stable zap-api-scan.py \
|
||||
-t /zap/wrk/openapi.json -f openapi -O http://localhost:8000 \
|
||||
-T 10 \
|
||||
-r zap-report.html -J zap-report.json -w zap-report.md \
|
||||
-z "-configfile /zap/auth.conf \
|
||||
-config globalexcludeurl.url_list.url(0).description=auth-etat \
|
||||
-config globalexcludeurl.url_list.url(0).enabled=true \
|
||||
-config globalexcludeurl.url_list.url(0).regex='.*/api/v1/auth/(login|password|logout-all|forgot-password|reset-password).*' \
|
||||
-config scanner.maxScanDurationInMins=15 \
|
||||
-config scanner.maxRuleDurationInMins=5" \
|
||||
2>&1 | tee "$RUNNER_TEMP/zap-stdout.log"
|
||||
|
||||
- name: Récupère les journaux de ZAP
|
||||
if: always()
|
||||
run: |
|
||||
mkdir -p zap-logs
|
||||
# ZAP journalise la valeur de chaque `-config`/`-configfile` chargé, y compris le jeton,
|
||||
# à un niveau visible sans `-d` : les copies publiées en artefact sont donc caviardées,
|
||||
# même si `::add-mask::` (posé à la création du jeton) protège déjà le journal du job.
|
||||
masque() { sed -E 's/(Bearer )[A-Za-z0-9._-]+/\1[MASQUE]/Ig'; }
|
||||
[ -f "$RUNNER_TEMP/zap-stdout.log" ] && masque < "$RUNNER_TEMP/zap-stdout.log" > zap-logs/zap-stdout.log
|
||||
docker cp zap:/home/zap/.ZAP/zap.log "$RUNNER_TEMP/zap-internal.log" 2>/dev/null || true
|
||||
[ -f "$RUNNER_TEMP/zap-internal.log" ] && masque < "$RUNNER_TEMP/zap-internal.log" > zap-logs/zap.log
|
||||
[ -f "$RUNNER_TEMP/api.log" ] && masque < "$RUNNER_TEMP/api.log" > zap-logs/api.log
|
||||
rm -f "$RUNNER_TEMP/zap-auth.conf"
|
||||
docker rm -f zap >/dev/null 2>&1 || true
|
||||
|
||||
# `continue-on-error` sur le scan ne doit pas faire passer pour vert un scan qui n'a rien
|
||||
# testé. Constaté une première fois : 2 URL importées sur 26 opérations, ZAP n'avait envoyé
|
||||
# que des requêtes vouées au 404. Le seuil est dérivé du contrat plutôt que d'un nombre fixe
|
||||
# : un contrat qui grossit ne doit pas rendre la garde plus permissive qu'elle ne l'était.
|
||||
- name: Vérifie que le contrat a bien été importé
|
||||
run: |
|
||||
attendu="$(python3 -c "
|
||||
import json
|
||||
d = json.load(open('zap-out/openapi.json'))
|
||||
methodes = ('get', 'post', 'put', 'patch', 'delete', 'head', 'options')
|
||||
print(sum(1 for chemin in d['paths'].values() for m in chemin if m in methodes))
|
||||
")"
|
||||
minimum=$((attendu * 80 / 100))
|
||||
importees="$(sed -n 's/.*Number of Imported URLs: \([0-9]*\).*/\1/p' "$RUNNER_TEMP/zap-stdout.log" | tail -1)"
|
||||
echo "URL importées depuis le contrat OpenAPI : ${importees:-aucune} (contrat : $attendu opérations, minimum accepté : $minimum)"
|
||||
if [ "${importees:-0}" -lt "$minimum" ]; then
|
||||
echo "::error::ZAP n'a importé que ${importees:-0} URL sur $attendu opérations du contrat OpenAPI (minimum attendu : $minimum, soit 80%). Le scan n'a pas testé l'API, voir zap-logs/zap.log dans l'artefact zap-report."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Deuxième garde-fou : le contrat peut être importé et ZAP n'obtenir que des erreurs
|
||||
# (constaté : base sans données, toutes les routes de site répondaient 404).
|
||||
#
|
||||
# Piège de conception, trouvé en répétant ce job en local avant de l'écrire ici : borner le
|
||||
# pourcentage de 4xx ne marche pas. Un scan actif fuzze délibérément un grand nombre
|
||||
# d'entrées invalides (identifiants inventés, méthodes non supportées...), donc même un scan
|
||||
# sain, contre l'API seedée juste au-dessus, reste à 98% de 4xx avec seulement 1% de 2xx :
|
||||
# c'est la forme normale d'un scan actif, pas un signe d'échec. Le signal qui distingue
|
||||
# vraiment un scan cassé (0% de 2xx, `insight.code.2xx` absent du rapport dans le premier
|
||||
# incident) d'un scan sain (2xx non nul, aussi faible soit-il) est donc l'absence de succès,
|
||||
# pas la part d'échecs. Dérivé de `zap-report.json` (champ structuré `insights[]`) plutôt
|
||||
# que du texte libre du rapport Markdown, qui aurait le même défaut de conception en plus
|
||||
# d'être fragile au format.
|
||||
- name: Vérifie que le scan a obtenu au moins une réponse de succès
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
rapport = json.load(open("zap-out/zap-report.json"))
|
||||
except FileNotFoundError:
|
||||
print("::error::Aucun rapport ZAP produit : le scan n'a rien testé.")
|
||||
sys.exit(1)
|
||||
|
||||
pourcentage_2xx = 0.0
|
||||
for insight in rapport.get("insights", []):
|
||||
if insight.get("key") == "insight.code.2xx":
|
||||
pourcentage_2xx = float(insight.get("statistic", 0))
|
||||
break
|
||||
|
||||
print(f"Pourcentage de réponses 2xx : {pourcentage_2xx}%")
|
||||
if pourcentage_2xx <= 0:
|
||||
print(
|
||||
"::error::Aucune réponse 2xx (succès) reçue : le scan n'a atteint aucune route "
|
||||
"réelle de l'API. Voir zap-logs/api.log et zap-logs/zap.log dans l'artefact "
|
||||
"zap-report."
|
||||
)
|
||||
sys.exit(1)
|
||||
PY
|
||||
|
||||
# Uniquement la synthèse (jusqu'à « Alert Detail » exclu) : `$GITHUB_STEP_SUMMARY` est
|
||||
# limité à 1 Mio, et cette étape tourne sous `always()` - son échec ferait échouer le job
|
||||
# après le passage des deux garde-fous, pour une simple raison de mise en forme. Le rapport
|
||||
# complet reste dans l'artefact `zap-report`.
|
||||
- name: Publie le résumé
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f zap-out/zap-report.md ]; then
|
||||
awk '/^## Alert Detail/{exit} {print}' zap-out/zap-report.md >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Rapport complet (HTML/JSON/Markdown) dans l'artefact \`zap-report\`." >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "Aucun rapport ZAP produit, voir le journal du job." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Publie les rapports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: zap-report
|
||||
path: |
|
||||
zap-out/
|
||||
zap-logs/
|
||||
if-no-files-found: warn
|
||||
|
||||
# Diagnostic de dernier recours : les journaux de l'API sont déjà dans l'artefact
|
||||
# (zap-logs/api.log) via l'étape « Récupère les journaux de ZAP » (always()), mais les
|
||||
# afficher directement dans le journal du job évite d'avoir à le télécharger pour un échec
|
||||
# évident (l'API n'a jamais démarré, par exemple).
|
||||
- name: Journal de l'API en cas d'échec
|
||||
if: failure() || steps.zap.outcome == 'failure'
|
||||
run: cat "$RUNNER_TEMP/api.log" || true
|
||||
@@ -52,6 +52,7 @@ jobs:
|
||||
done
|
||||
echo "L'API ne répond pas après 3 minutes" >&2
|
||||
cd "/srv/enervision/${ENVIRONNEMENT}"
|
||||
docker compose ps
|
||||
docker compose logs --tail=50 backend proxy
|
||||
compose="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
|
||||
$compose ps
|
||||
$compose logs --tail=50 backend proxy
|
||||
exit 1
|
||||
|
||||
@@ -17,10 +17,10 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 26
|
||||
cache: npm
|
||||
cache-dependency-path: apps/frontend/package-lock.json
|
||||
|
||||
@@ -33,10 +33,10 @@ jobs:
|
||||
name: Audit des dépendances
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 26
|
||||
# Seuil high : une vulnérabilité moderate de devDependency ne doit pas bloquer une livraison.
|
||||
- run: npm audit --audit-level=high --package-lock-only
|
||||
working-directory: apps/frontend
|
||||
@@ -45,10 +45,10 @@ jobs:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 26
|
||||
cache: npm
|
||||
cache-dependency-path: apps/frontend/package-lock.json
|
||||
- name : Installation des dépendances (Front)
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
run: npm test --watch=false --code-coverage --coverageReporters=lcov
|
||||
working-directory: apps/frontend
|
||||
- name: Upload coverage
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-coverage
|
||||
path: apps/frontend/coverage/frontend/lcov.info
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Infra
|
||||
|
||||
# Pourquoi : le Terraform du dépôt est resté cassé sans que rien ne le dise, faute de job qui le
|
||||
# joue. Ce workflow n'applique rien : il vérifie le formatage et la validité de chaque racine.
|
||||
# Piège : la boucle parcourt `environments/*`, pour qu'une racine ajoutée soit couverte sans
|
||||
# toucher à ce fichier.
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "infra/terraform/**"
|
||||
- ".github/workflows/infra.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "infra/terraform/**"
|
||||
- ".github/workflows/infra.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: infra-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
terraform:
|
||||
name: Formatage et validation Terraform
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Récupère le dépôt
|
||||
uses: actions/checkout@v7
|
||||
|
||||
# Action tierce, donc epinglee sur un SHA de commit et pas sur un tag mobile : un tag se
|
||||
# redeplace, et ce workflow tourne avec les droits du depot (regle Sonar githubactions:S7637).
|
||||
- name: Installe Terraform
|
||||
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
||||
with:
|
||||
terraform_version: 1.16.3
|
||||
terraform_wrapper: false
|
||||
|
||||
- name: Vérifie le formatage
|
||||
run: terraform fmt -check -recursive infra/terraform
|
||||
|
||||
- name: Valide chaque racine
|
||||
run: |
|
||||
for racine in infra/terraform/environments/*/; do
|
||||
echo "::group::${racine}"
|
||||
terraform -chdir="${racine}" init -backend=false -input=false
|
||||
terraform -chdir="${racine}" validate
|
||||
echo "::endgroup::"
|
||||
done
|
||||
@@ -30,10 +30,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Récupère le dépôt
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Installe uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: ml/uv.lock
|
||||
@@ -67,12 +67,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Récupère le dépôt
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
# 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@v5
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Analyse le code livré (bloquant à partir de MEDIUM)
|
||||
run: uvx bandit==1.9.4 --recursive enervision_ml --severity-level medium --confidence-level medium
|
||||
|
||||
@@ -24,10 +24,10 @@ jobs:
|
||||
build-front:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 26
|
||||
cache: npm
|
||||
cache-dependency-path: apps/frontend/package-lock.json
|
||||
|
||||
@@ -40,10 +40,10 @@ jobs:
|
||||
needs: build-front
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 26
|
||||
cache: npm
|
||||
cache-dependency-path: apps/frontend/package-lock.json
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
working-directory: apps/frontend
|
||||
|
||||
- name: Upload coverage
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-coverage
|
||||
path: apps/frontend/coverage/frontend/lcov.info
|
||||
@@ -64,9 +64,9 @@ jobs:
|
||||
build-back:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Installe uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: apps/backend/uv.lock
|
||||
@@ -95,9 +95,9 @@ jobs:
|
||||
needs: build-back
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Installe uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: apps/backend/uv.lock
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
working-directory: apps/backend
|
||||
|
||||
- name: Upload coverage
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: backend-coverage
|
||||
path: apps/backend/coverage.xml
|
||||
@@ -115,9 +115,9 @@ jobs:
|
||||
test-ml:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Installe uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: ml/uv.lock
|
||||
@@ -135,7 +135,7 @@ jobs:
|
||||
working-directory: ml
|
||||
|
||||
- name: Upload coverage
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ml-coverage
|
||||
path: ml/coverage.xml
|
||||
@@ -143,23 +143,26 @@ jobs:
|
||||
sonarqube:
|
||||
needs: [build-front, build-back, test-front, test-back, test-ml]
|
||||
name: SonarQube
|
||||
# Pourquoi : GitHub ne fournit pas les secrets aux workflows lancés par dependabot[bot].
|
||||
# Sans SONAR_TOKEN le scan échoue sans rien analyser ; build et tests restent joués.
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Téléchargement du rapport de couverture (Front)
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: frontend-coverage
|
||||
path: apps/frontend/coverage/frontend
|
||||
- name: Téléchargement du rapport de couverture (Back)
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: backend-coverage
|
||||
path: apps/backend
|
||||
- name: Téléchargement du rapport de couverture (ML)
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ml-coverage
|
||||
path: ml
|
||||
|
||||
@@ -40,7 +40,6 @@ kubeconfig
|
||||
# Airflow
|
||||
etl/airflow/logs/
|
||||
airflow.db
|
||||
airflow-webserver.pid
|
||||
standalone_admin_password.txt
|
||||
|
||||
# Environnement et secrets
|
||||
|
||||
@@ -67,7 +67,7 @@ services-up: ## Démarre les services conteneurisés dont `make dev` dépend (ba
|
||||
docker compose up -d db mailpit
|
||||
@$(MAKE) --no-print-directory db-wait
|
||||
@$(MAKE) --no-print-directory db-ensure-airflow
|
||||
docker compose up -d airflow-init airflow-webserver airflow-scheduler
|
||||
docker compose up -d airflow-init airflow-apiserver airflow-scheduler airflow-dag-processor
|
||||
|
||||
dev-backend: ## Lance l'API seule en rechargement à chaud
|
||||
@echo "backend -> http://localhost:8000 (docs sur /docs)"
|
||||
@@ -132,12 +132,12 @@ airflow-test: ## Verifie que les DAGs s'importent sans erreur et ont la structur
|
||||
|
||||
airflow-check: airflow-lint airflow-test ## Chaîne de vérification complète des DAGs Airflow
|
||||
|
||||
airflow-up: db-ensure-airflow ## Démarre Airflow (webserver + scheduler, LocalExecutor). db-up requis avant.
|
||||
docker compose up -d airflow-init airflow-webserver airflow-scheduler
|
||||
airflow-up: db-ensure-airflow ## Démarre Airflow (api-server + scheduler + dag-processor, LocalExecutor). db-up requis avant.
|
||||
docker compose up -d airflow-init airflow-apiserver airflow-scheduler airflow-dag-processor
|
||||
@echo "airflow -> http://localhost:$${AIRFLOW_PORT:-8080}"
|
||||
|
||||
airflow-down: ## Arrête le webserver et le scheduler Airflow
|
||||
docker compose stop airflow-webserver airflow-scheduler
|
||||
airflow-down: ## Arrête l'api-server, le scheduler et le dag-processor Airflow
|
||||
docker compose stop airflow-apiserver airflow-scheduler airflow-dag-processor
|
||||
|
||||
airflow-logs: ## Suit les journaux du scheduler Airflow (où tournent les tâches, LocalExecutor)
|
||||
docker compose logs -f airflow-scheduler
|
||||
|
||||
@@ -9,8 +9,10 @@ series temporelles energetiques, deployee sur une machine on-premise.
|
||||
|-------|----------------------------------------------------------|
|
||||
| J1 | Valider la préparation de l'environnement et du repo |
|
||||
| J2 | Valider le périmètre retenu et les choix technologiques |
|
||||
| J3 | Valider l'architecture et la gestion de la sécurité |
|
||||
| J4 | Valider la robustesse et assurer les livrables |
|
||||
| J3 | Ingestion & backend |
|
||||
| J4 | Architecture, sécurité & frontend |
|
||||
| J5 | Valider la robustesse et assurer les livrables |
|
||||
| J6 | Amélioration possible |
|
||||
|
||||
Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.md](docs/architecture/00-vue-ensemble.md).
|
||||
|
||||
@@ -18,15 +20,15 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m
|
||||
|
||||
| Domaine | Technologie | Emplacement | Etat |
|
||||
|------------|-------------------------------------|---------------------|---------------|
|
||||
| Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise |
|
||||
| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Tableau de bord |
|
||||
| Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise |
|
||||
| Backend | FastAPI, Python 3.14 | `apps/backend` | En place |
|
||||
| Frontend | Angular 22, Node 26 | `apps/frontend` | En place |
|
||||
| Base | PostgreSQL 17 + TimescaleDB | `db` | En place |
|
||||
| ETL | Apache Airflow | `etl/airflow` | Quatre DAGs |
|
||||
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
|
||||
| Reverse proxy | Nginx, TLS | `infra/proxy` | En place |
|
||||
| CI/CD | GitHub Actions | `.github/workflows` | Backend en place |
|
||||
| CI/CD | GitHub Actions | `.github/workflows` | En place |
|
||||
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser |
|
||||
| ML | LightGBM, MLflow | `ml` | Entrainement initialise |
|
||||
| ML | LightGBM, MLflow | `ml` | En place |
|
||||
|
||||
Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend
|
||||
sert un tableau de bord sur `/dashboard`, dont les données proviennent de fixtures : les endpoints
|
||||
@@ -92,8 +94,9 @@ et frontend en rechargement a chaud sur le poste.
|
||||
| Mailpit | <http://localhost:8025> |
|
||||
|
||||
Le `.env` doit porter les cles Airflow avant le premier `make dev` : `AIRFLOW_FERNET_KEY`,
|
||||
`AIRFLOW_WEBSERVER_SECRET_KEY`, `AIRFLOW_APP_SECRET_KEY` et `AIRFLOW_ADMIN_PASSWORD`. Sans elles
|
||||
`airflow-init` refuse de demarrer, et `airflow-webserver` comme `airflow-scheduler` avec lui.
|
||||
`AIRFLOW_API_SECRET_KEY`, `AIRFLOW_JWT_SECRET`, `AIRFLOW_APP_SECRET_KEY` et
|
||||
`AIRFLOW_ADMIN_PASSWORD`. Sans elles `airflow-init` refuse de demarrer, et `airflow-apiserver`,
|
||||
`airflow-scheduler` et `airflow-dag-processor` avec lui.
|
||||
|
||||
Les cibles d'origine restent disponibles pour ne demarrer qu'une partie : `make db-up`,
|
||||
`make airflow-up`, `make dev-backend`, `make dev-frontend`.
|
||||
|
||||
@@ -48,7 +48,9 @@ SettingsDep = Annotated[Settings, Depends(get_settings)]
|
||||
|
||||
CODE_CHANGEMENT_REQUIS = "password_change_required"
|
||||
|
||||
_porteur = HTTPBearer(auto_error=False, scheme_name="Jeton d'accès")
|
||||
# Nom ASCII : un outillage tiers (ZAP, cf. .github/workflows/dast.yml) peut mal analyser un nom
|
||||
# de schéma accentué dans le contrat OpenAPI. Piège vécu, pas anticipé.
|
||||
_porteur = HTTPBearer(auto_error=False, scheme_name="JetonAcces")
|
||||
CredentialsDep = Annotated[HTTPAuthorizationCredentials | None, Depends(_porteur)]
|
||||
|
||||
|
||||
|
||||
@@ -94,7 +94,9 @@ TAGS: Final[list[dict[str, Any]]] = [
|
||||
|
||||
cookie_de_rafraichissement = APIKeyCookie(
|
||||
name=REFRESH_COOKIE_DEFAUT,
|
||||
scheme_name="Cookie de rafraîchissement",
|
||||
# Nom ASCII : un outillage tiers (ZAP, cf. .github/workflows/dast.yml) peut mal analyser un
|
||||
# nom de schéma accentué dans le contrat OpenAPI. Piège vécu, pas anticipé.
|
||||
scheme_name="CookieRafraichissement",
|
||||
description=(
|
||||
"Cookie `HttpOnly` posé par `/auth/login` et tourné par `/auth/refresh`. Il prend le "
|
||||
"préfixe `__Secure-` dès que l'API tourne derrière TLS, et n'est émis que vers "
|
||||
|
||||
+22
-22
@@ -213,7 +213,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Cookie de rafraîchissement": []
|
||||
"CookieRafraichissement": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -252,7 +252,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Cookie de rafraîchissement": []
|
||||
"CookieRafraichissement": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -301,7 +301,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -347,7 +347,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -423,7 +423,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -673,7 +673,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -757,7 +757,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -771,7 +771,7 @@
|
||||
"operationId": "update_user_api_v1_users__user_id__patch",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
@@ -889,7 +889,7 @@
|
||||
"operationId": "reset_password_api_v1_users__user_id__password_reset_post",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
@@ -1023,7 +1023,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1037,7 +1037,7 @@
|
||||
"operationId": "get_site_api_v1_sites__site_id__get",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
@@ -1124,7 +1124,7 @@
|
||||
"operationId": "get_current_api_v1_sites__site_id__current_get",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
@@ -1211,7 +1211,7 @@
|
||||
"operationId": "list_alerts_api_v1_alerts_get",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
@@ -1361,7 +1361,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1375,7 +1375,7 @@
|
||||
"operationId": "get_recommendation_api_v1_recommendations__recommendation_id__get",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
@@ -1462,7 +1462,7 @@
|
||||
"operationId": "generate_recommendations_api_v1_recommendations_generate_post",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
@@ -1588,7 +1588,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1602,7 +1602,7 @@
|
||||
"operationId": "list_readings_api_v1_readings_get",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
@@ -1799,7 +1799,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1855,7 +1855,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3225,13 +3225,13 @@
|
||||
}
|
||||
},
|
||||
"securitySchemes": {
|
||||
"Cookie de rafraîchissement": {
|
||||
"CookieRafraichissement": {
|
||||
"type": "apiKey",
|
||||
"description": "Cookie `HttpOnly` posé par `/auth/login` et tourné par `/auth/refresh`. Il prend le préfixe `__Secure-` dès que l'API tourne derrière TLS, et n'est émis que vers `/api/v1/auth`.",
|
||||
"in": "cookie",
|
||||
"name": "ev_refresh"
|
||||
},
|
||||
"Jeton d'accès": {
|
||||
"JetonAcces": {
|
||||
"type": "http",
|
||||
"scheme": "bearer"
|
||||
}
|
||||
|
||||
@@ -104,8 +104,8 @@ def test_the_rate_limit_documents_the_delay_header(schema: dict[str, Any]) -> No
|
||||
def test_the_refresh_cookie_appears_in_the_security_schemes(schema: dict[str, Any]) -> None:
|
||||
schemes = schema["components"]["securitySchemes"]
|
||||
|
||||
assert schemes["Cookie de rafraîchissement"]["in"] == "cookie"
|
||||
assert schemes["Cookie de rafraîchissement"]["name"] == "ev_refresh"
|
||||
assert schemes["CookieRafraichissement"]["in"] == "cookie"
|
||||
assert schemes["CookieRafraichissement"]["name"] == "ev_refresh"
|
||||
|
||||
|
||||
def test_each_tag_used_by_a_route_is_described(schema: dict[str, Any]) -> None:
|
||||
|
||||
Generated
+37
-37
@@ -655,31 +655,31 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pandas"
|
||||
version = "3.0.5"
|
||||
version = "3.0.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e2/17/d7b106e05bfa642e8694451e7d3d759c6a241c5386a5d962e4f66c047e06/pandas-3.0.6.tar.gz", hash = "sha256:66b07ef7315a31bfe1089cd3d71a7de781c9dca986762d0b4fe7c0ef17465d10", size = 4667686, upload-time = "2026-09-17T23:23:18.345Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/2f/cf6aae281264f4463f0875bcbb15fd2bb6d291cc535187dad1732475e4a9/pandas-3.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36", size = 10390034, upload-time = "2026-07-22T22:18:49.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c", size = 9980065, upload-time = "2026-07-22T22:18:52.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/f1/598503ce8d7e3c35601e0747ba288c7864baae66380725bc12f13f884dfe/pandas-3.0.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0", size = 10545532, upload-time = "2026-07-22T22:18:54.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b", size = 10963120, upload-time = "2026-07-22T22:18:57.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/25/86e0f4451874eb79e688deeebe3c451fec4557f8952005818d800ee8ac7e/pandas-3.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94", size = 11563178, upload-time = "2026-07-22T22:18:59.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/45/8643daa3b4147e433adfcccefdd0380d3aad79d86b15d8999730fe1944d5/pandas-3.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da", size = 12028708, upload-time = "2026-07-22T22:19:02.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92", size = 9951806, upload-time = "2026-07-22T22:19:04.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/32/7ac03886b304049a9d2625ee88f59af760d8a93bd30ed9239bce7b9869a8/pandas-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85", size = 9238297, upload-time = "2026-07-22T22:19:06.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/ed/1d1f2ee5547d5167face2376d11c8b2a4c7bfff5a416ee7a9046891fab1e/pandas-3.0.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca", size = 10849690, upload-time = "2026-07-22T22:19:09.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/55/17e17152e98fbb0c4b1e562bc65387a2f20a80db0f4a86bf8d3a0e4248d4/pandas-3.0.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da", size = 10509945, upload-time = "2026-07-22T22:19:11.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/90/817d44dbf83facf9556f33576d9af0a241981e7bb5c00606c0bcb5df8dda/pandas-3.0.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a", size = 10392197, upload-time = "2026-07-22T22:19:14.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/da/889f00c0a6f5aa1545add70abbf01502dff87ab577adb855bd631c54d2f2/pandas-3.0.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040", size = 10862726, upload-time = "2026-07-22T22:19:16.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/98/f1e934fb3c98fce859c6147c6785816c7b5b9ab7821115c5d8c4de9842b9/pandas-3.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd", size = 11414864, upload-time = "2026-07-22T22:19:18.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/be/d448af7d657d82e1888dd8551f79c6d6fb161080b5b9752d84d910ec2319/pandas-3.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c", size = 11925105, upload-time = "2026-07-22T22:19:21.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/c1/ccb4238212c8c4f496c584f3044d94e0c030ed8e1d68999db46c91c2242f/pandas-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea", size = 10387612, upload-time = "2026-07-22T22:19:24.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/55/1a8875395b05ccd572cbca0b9255dcd2db6e6508e632a558c1a6884b39ad/pandas-3.0.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ee913a91669056c1de1a6b733fbfeab711de9e54e3bee2dfa5fe79d9457247d1", size = 10492213, upload-time = "2026-09-17T23:21:40.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/61/47ae13476995cc8a40cd609e93e7cf11f273d8692925c2903cb6d38aa0d1/pandas-3.0.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ff51a4459ed036e93d1eb1bb5e6e7b28685d3cb6b7c12b91c05b31024e234729", size = 10156618, upload-time = "2026-09-17T23:21:44.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/f2/cc5f2adb8d6e86a85d9fb5128f8cf205a61189336f70d1f7faf0d1b53ec9/pandas-3.0.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654aae059295dbba6ecd2328ca12712a2cf1676214c8699f1c29213f7ccf9c34", size = 10375479, upload-time = "2026-09-17T23:21:47.159Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/ba/ffdcb19be4ff6bfe7d969e7cef2c567c633df5a3a1cc1053394ad053bca8/pandas-3.0.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f51d7f651c8054c5e82a69265c98082e795d1442df7ca6edc3a545d61214b1", size = 10783244, upload-time = "2026-09-17T23:21:50.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/5b/e150075b2c6eb69fae896f2d9239bc6ed07db97735971d53d66de6553460/pandas-3.0.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:22172a92e7ee678ec0140c7af4fc9366b55413834a1cd86af78b3caa0b0574de", size = 11382223, upload-time = "2026-09-17T23:21:53.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/8a/b441c587dc7355bf6e1f68a91b4f76a6c29740f0c23be3acc5d4ebbeea6d/pandas-3.0.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:583be68728a31d0d750d5b8d9e00f02b153df0d4655f858bde93cb84cfc4227c", size = 11854881, upload-time = "2026-09-17T23:21:56.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/e9/f43410fada510b43fec09993c08f552086c3d247d3ee801a678f3cb10ea5/pandas-3.0.6-cp314-cp314-win_amd64.whl", hash = "sha256:77ccbe5057aece6fc172b9b77f19c04335af6882bc2e10c8f3ee4e6bfb3da553", size = 9791672, upload-time = "2026-09-17T23:21:59.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/9e/db14c059c21f9baa1907d436f8bf30e0c76c6288225c5e8b79a08ba8b2c5/pandas-3.0.6-cp314-cp314-win_arm64.whl", hash = "sha256:fb625f426b375bcc96e3a04c5d5d266cd7be6ae5d6866e0e703382ab5164068c", size = 9121246, upload-time = "2026-09-17T23:22:02.123Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ba/bad0f8dac020ab38a8637fddab01a57a82da7a496a6e6f19590aad53ab62/pandas-3.0.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9e492cd4bdba6778de4fe0df7f4590c012161ebcf9902dce01b01dc683105514", size = 10920612, upload-time = "2026-09-17T23:22:05.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/a9/b500982e9aac6d52a58da4ad3f11e14168a315b06906b3f397c427878065/pandas-3.0.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d7dcd21238cbb4828ff148481ba01cac8946dc5121457b5aeba28636f8f99a60", size = 10570213, upload-time = "2026-09-17T23:22:08.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/fa/e6ecd0073c98be8f840ac3125b955272835d7d9fd69f5944383b164deb5e/pandas-3.0.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ff482fa91fa2bafd92e8fe66ce3645c851824310f295c1f0a2f96e928fc4541", size = 10252356, upload-time = "2026-09-17T23:22:11.302Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/f5/001e230a7a7803590d9275a1a3f7e1bb605e3a495cfe5e8d3a532090621b/pandas-3.0.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:db7ec631f26223beee8e5c9e0b8f23c24d8197bbd1d982421d4e3188bea51965", size = 10655952, upload-time = "2026-09-17T23:22:14.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/ab/bab587148a3852c96aae26c4b5f9e04ce2221801ad94e166b4fbf969ede0/pandas-3.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd75ed0c840f709fc2ae26ddd9534ac77ca1a48ac0cce521a74acaa85f3340a7", size = 11274177, upload-time = "2026-09-17T23:22:17.352Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/32/74b48d87df2b80892d713c149abfe36d5db4de41b4eccb042a2bc07dafc1/pandas-3.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ef738d71d1059245b6bb03e312be06d8b3821326a83486c1ad03b9aba3710e44", size = 11719041, upload-time = "2026-09-17T23:22:20.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/c6/d64b72d64d7eb0fad9fe424d34138e45dee70ddbea1360dcd0adf30e28f6/pandas-3.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:429d9df32731ab01383ed98f2baa7a60368090d1a94fc06019a12062510e8630", size = 10191388, upload-time = "2026-09-17T23:22:23.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/30/5e5b2ccabeca73ae2b03fc82bca3eabb7466cf43737f05ac08d591665d47/pandas-3.0.6-cp314-cp314t-win_arm64.whl", hash = "sha256:a4dbd4dc65cbe645b92b8785d0f96dd7311010dc6606cf620e51b07b8788a12a", size = 9387796, upload-time = "2026-09-17T23:22:26.64Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -936,27 +936,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.16.7"
|
||||
version = "0.16.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# ==================
|
||||
|
||||
# Image pour frontend
|
||||
FROM node:24-alpine3.22 AS builder
|
||||
FROM node:26-alpine3.22 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -23,7 +23,7 @@ RUN npm run build
|
||||
# ==================
|
||||
|
||||
|
||||
FROM nginx:1.28-alpine AS runner
|
||||
FROM nginx:1.31-alpine AS runner
|
||||
|
||||
# Copie de la configuration de nginx
|
||||
COPY --chown=root:root --chmod=755 nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
Generated
+95
-174
@@ -8,12 +8,12 @@
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@angular/common": "^22.1.0",
|
||||
"@angular/compiler": "^22.1.0",
|
||||
"@angular/core": "^22.1.0",
|
||||
"@angular/forms": "^22.1.0",
|
||||
"@angular/platform-browser": "^22.1.0",
|
||||
"@angular/router": "^22.1.0",
|
||||
"@angular/common": "^22.1.7",
|
||||
"@angular/compiler": "^22.1.7",
|
||||
"@angular/core": "^22.1.7",
|
||||
"@angular/forms": "^22.1.7",
|
||||
"@angular/platform-browser": "^22.1.7",
|
||||
"@angular/router": "^22.1.7",
|
||||
"chart.js": "^4.5.1",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
@@ -21,21 +21,14 @@
|
||||
"devDependencies": {
|
||||
"@angular/build": "^22.1.8",
|
||||
"@angular/cli": "^22.1.8",
|
||||
"@angular/compiler-cli": "^22.1.0",
|
||||
"@angular/compiler-cli": "^22.1.7",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"jsdom": "^28.0.0",
|
||||
"prettier": "^3.8.1",
|
||||
"jsdom": "^30.1.0",
|
||||
"prettier": "^3.9.8",
|
||||
"typescript": "~6.0.2",
|
||||
"vitest": "^4.0.8"
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@acemir/cssom": {
|
||||
"version": "0.9.31",
|
||||
"resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz",
|
||||
"integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||
@@ -255,9 +248,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/common": {
|
||||
"version": "22.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@angular/common/-/common-22.1.6.tgz",
|
||||
"integrity": "sha512-giuH+jJvo6YbBxbKofJCXvq6k8g1Z/xCAvh4piNFSSS6/toXTLCeZ+snr6Stw5b2wRbArM5Q5nDeuto3NqVPnQ==",
|
||||
"version": "22.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/common/-/common-22.1.7.tgz",
|
||||
"integrity": "sha512-SLW4AdH2Y8G9SM/iFRNpCQtkmvq55RcIQWsADT9ypZ7FxxcnJRsr4Q/WnbUBbuCslsCkrgzU1i9WDiW0m7gcnw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
@@ -266,14 +259,14 @@
|
||||
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/core": "22.1.6",
|
||||
"@angular/core": "22.1.7",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/compiler": {
|
||||
"version": "22.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-22.1.6.tgz",
|
||||
"integrity": "sha512-JjOUm/qD338+fGfZvxSNn/vTUiVqNwOiPzacInVUq1eVp7Jev+cnvEwXA2cFJkYZoy3Imz6wHoMvTUZNN8cbKQ==",
|
||||
"version": "22.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-22.1.7.tgz",
|
||||
"integrity": "sha512-qhoHAByh805nT4Xy4Wd2kE5FvkTc7wcg8Qps+EH6+z4KRfwGiL5cwdgooGWjTAldFCeqTgJ0BO/0dB6GHZjdkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
@@ -283,9 +276,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/compiler-cli": {
|
||||
"version": "22.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-22.1.6.tgz",
|
||||
"integrity": "sha512-C1fQuaSLnibhfbb7Im/vurdBEcfQk+/GqPkY4+dgEKd4EPleO0xWlD+k5DwyNFX8a9w7HebWfo0Zl66HuaEwOQ==",
|
||||
"version": "22.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-22.1.7.tgz",
|
||||
"integrity": "sha512-VGg8Kyt0YymQfnUUFk79vFaM+x5xmIMHy+BZh3P2Lh/uxqCXTWhJJH8kOHKtIGGH5/wKhBEmSy2CezdcIuM9xg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -306,7 +299,7 @@
|
||||
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/compiler": "22.1.6",
|
||||
"@angular/compiler": "22.1.7",
|
||||
"typescript": ">=6.0 <6.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -316,9 +309,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/core": {
|
||||
"version": "22.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@angular/core/-/core-22.1.6.tgz",
|
||||
"integrity": "sha512-3Ln9YYOhsaU2vPufnpcu6C4dlmX4e/nJTlggVcKMT7bGZH5KlEtw3h0uh9YfANv8YhQCEk1AcuRI7KpBnh5ing==",
|
||||
"version": "22.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/core/-/core-22.1.7.tgz",
|
||||
"integrity": "sha512-T9bt1PIgoN7n+TxGHTUgu0EsomlGMQ84YMXbMB+N6HhD8C1qZxn0uCbnAtCUjcecg1am8ioKxNTO0eII9ouioQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
@@ -327,7 +320,7 @@
|
||||
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/compiler": "22.1.6",
|
||||
"@angular/compiler": "22.1.7",
|
||||
"rxjs": "^6.5.3 || ^7.4.0",
|
||||
"zone.js": "~0.15.0 || ~0.16.0"
|
||||
},
|
||||
@@ -341,9 +334,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/forms": {
|
||||
"version": "22.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-22.1.6.tgz",
|
||||
"integrity": "sha512-rfV4G4UB4l69yXSRvhaHPzTMIruvBlRO+ak9NtTcYMnsoj8O5ZCyPv0ledGIrLTBwGSI3X1j4nr9aoV+Rj/n+Q==",
|
||||
"version": "22.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-22.1.7.tgz",
|
||||
"integrity": "sha512-oc0DT39C3ZboJpDx8xKUCltgn56LHi0kbv+ThNK/dCSZLgjF2nd+muMMRYF+amdljQp2q7+hm2ORmhON3CNFog==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
@@ -354,16 +347,16 @@
|
||||
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "22.1.6",
|
||||
"@angular/core": "22.1.6",
|
||||
"@angular/platform-browser": "22.1.6",
|
||||
"@angular/common": "22.1.7",
|
||||
"@angular/core": "22.1.7",
|
||||
"@angular/platform-browser": "22.1.7",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/platform-browser": {
|
||||
"version": "22.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-22.1.6.tgz",
|
||||
"integrity": "sha512-jrRi6zpdz+jOle5l0OW7QL0a8xPgdnxWT5FrJabbiNKfYzQfqKcaAu02l8uJoJxtGb8fLQ8pbjkPpZEvKO2z+w==",
|
||||
"version": "22.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-22.1.7.tgz",
|
||||
"integrity": "sha512-4dT3qGw3ZHVQUcHKALzMx9MTo+pxAMzHIPxc8HUESklqXfq6GRaIIfFMvE0RsquI9iqu6admD6lOAEzLWS8DxA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
@@ -372,9 +365,9 @@
|
||||
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/animations": "22.1.6",
|
||||
"@angular/common": "22.1.6",
|
||||
"@angular/core": "22.1.6"
|
||||
"@angular/animations": "22.1.7",
|
||||
"@angular/common": "22.1.7",
|
||||
"@angular/core": "22.1.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@angular/animations": {
|
||||
@@ -383,9 +376,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/router": {
|
||||
"version": "22.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@angular/router/-/router-22.1.6.tgz",
|
||||
"integrity": "sha512-ex0vrkcVyJJdn7NzZXun+5ctaPPLTZJ/gE7A3g1dun7BM3g4Z6zT738mODFeJgtPtJNf4bTs2gpgvyegvzWH7w==",
|
||||
"version": "22.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/router/-/router-22.1.7.tgz",
|
||||
"integrity": "sha512-CxYa2Ym5L0WmqToHU2I0Z4VEpocdryLlvx5svLfo9M3uOaP3/9icB/gjS/vXPbbQxV8/GZ59E/7/LF14anIoRg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
@@ -394,60 +387,45 @@
|
||||
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "22.1.6",
|
||||
"@angular/core": "22.1.6",
|
||||
"@angular/platform-browser": "22.1.6",
|
||||
"@angular/common": "22.1.7",
|
||||
"@angular/core": "22.1.7",
|
||||
"@angular/platform-browser": "22.1.7",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "5.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
||||
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.0.tgz",
|
||||
"integrity": "sha512-IxddtGnnoidVSO6O/aiTueptbTThHvi2qGHu9/AWuD1J7i86Ox+jl+Y5SXcDH1RixzQlP2cjH/PjnlImZiAurQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||
"@csstools/css-calc": "^3.2.0",
|
||||
"@csstools/css-color-parser": "^4.1.0",
|
||||
"@csstools/css-calc": "^3.3.0",
|
||||
"@csstools/css-color-parser": "^4.2.0",
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
"@csstools/css-tokenizer": "^4.0.0",
|
||||
"lru-cache": "^11.5.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector": {
|
||||
"version": "6.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz",
|
||||
"integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==",
|
||||
"version": "9.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.1.4.tgz",
|
||||
"integrity": "sha512-ynDE7RDZM1z+YuSU+iGhRp8WcSXHqK9+G32ZEzsL35TKSoy72fYR7VfrJCQkWe3ciiIWGSTAIBc9XB5jVpqyTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/nwsapi": "^2.3.9",
|
||||
"bidi-js": "^1.0.3",
|
||||
"css-tree": "^3.1.0",
|
||||
"bidi-js": "^1.1.0",
|
||||
"css-tree": "^3.2.1",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.2.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/generational-cache": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
|
||||
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"lru-cache": "^11.5.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/nwsapi": {
|
||||
"version": "2.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
|
||||
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz",
|
||||
@@ -854,9 +832,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.13.tgz",
|
||||
"integrity": "sha512-i9ZylF5QNhmNfPA9l0vHAWK4kPrbIp6g9lKgaiIFsIBz2F/WNB7OLrzlNNcCOm+h42bkaSD2v1PG+IBPHhc3ZA==",
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz",
|
||||
"integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -879,9 +857,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
|
||||
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.1.tgz",
|
||||
"integrity": "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -4490,22 +4468,6 @@
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/cssstyle": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz",
|
||||
"integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^5.0.1",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.0.28",
|
||||
"css-tree": "^3.1.0",
|
||||
"lru-cache": "^11.2.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||
@@ -5283,30 +5245,6 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent/node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz",
|
||||
@@ -5529,39 +5467,38 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "28.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz",
|
||||
"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
|
||||
"version": "30.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.0.tgz",
|
||||
"integrity": "sha512-h/Q+H+KmlGf49ITw3TrykSVbg04S75S2O7ZtJc9gMGOWBrQRZmx5AJIKLtJ01e6Kszl+/tpFthCVqxNifxhoqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@acemir/cssom": "^0.9.31",
|
||||
"@asamuzakjp/dom-selector": "^6.8.1",
|
||||
"@asamuzakjp/css-color": "^7.0.0",
|
||||
"@asamuzakjp/dom-selector": "^9.1.2",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@exodus/bytes": "^1.11.0",
|
||||
"cssstyle": "^6.0.1",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.13",
|
||||
"@exodus/bytes": "^1.15.1",
|
||||
"css-tree": "^3.2.1",
|
||||
"data-urls": "^7.0.0",
|
||||
"decimal.js": "^10.6.0",
|
||||
"html-encoding-sniffer": "^6.0.0",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"parse5": "^8.0.0",
|
||||
"lru-cache": "^11.5.2",
|
||||
"parse5": "^8.0.1",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^6.0.0",
|
||||
"undici": "^7.21.0",
|
||||
"tough-cookie": "^6.0.2",
|
||||
"undici": "^8.10.2",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^8.0.1",
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.0",
|
||||
"whatwg-url": "^17.1.1",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.0.0"
|
||||
"canvas": "^3.2.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
@@ -5569,28 +5506,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"node_modules/jsdom/node_modules/whatwg-url": {
|
||||
"version": "17.1.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.1.tgz",
|
||||
"integrity": "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
"@exodus/bytes": "^1.15.1",
|
||||
"tr46": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
"node": "^22.14.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
@@ -6708,9 +6636,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.9.6",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
|
||||
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
|
||||
"version": "3.9.8",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.8.tgz",
|
||||
"integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -7308,13 +7236,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -7469,13 +7390,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.29.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz",
|
||||
"integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==",
|
||||
"version": "8.10.2",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz",
|
||||
"integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
|
||||
+10
-10
@@ -12,12 +12,12 @@
|
||||
"private": true,
|
||||
"packageManager": "npm@11.19.0",
|
||||
"dependencies": {
|
||||
"@angular/common": "^22.1.0",
|
||||
"@angular/compiler": "^22.1.0",
|
||||
"@angular/core": "^22.1.0",
|
||||
"@angular/forms": "^22.1.0",
|
||||
"@angular/platform-browser": "^22.1.0",
|
||||
"@angular/router": "^22.1.0",
|
||||
"@angular/common": "^22.1.7",
|
||||
"@angular/compiler": "^22.1.7",
|
||||
"@angular/core": "^22.1.7",
|
||||
"@angular/forms": "^22.1.7",
|
||||
"@angular/platform-browser": "^22.1.7",
|
||||
"@angular/router": "^22.1.7",
|
||||
"chart.js": "^4.5.1",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
@@ -25,11 +25,11 @@
|
||||
"devDependencies": {
|
||||
"@angular/build": "^22.1.8",
|
||||
"@angular/cli": "^22.1.8",
|
||||
"@angular/compiler-cli": "^22.1.0",
|
||||
"@angular/compiler-cli": "^22.1.7",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"jsdom": "^28.0.0",
|
||||
"prettier": "^3.8.1",
|
||||
"jsdom": "^30.1.0",
|
||||
"prettier": "^3.9.8",
|
||||
"typescript": "~6.0.2",
|
||||
"vitest": "^4.0.8"
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- Base de metadonnees Airflow (webserver + scheduler, LocalExecutor). Separee de la base
|
||||
-- Base de metadonnees Airflow (api-server + scheduler + dag-processor, LocalExecutor). Separee de la base
|
||||
-- applicative : les tables internes d'Airflow (dag_run, task_instance, ...) n'ont rien a faire
|
||||
-- dans le schema metier. Meme conteneur Postgres que `enervision`/`enervision_test` plutot qu'un
|
||||
-- service dedie, pour ne pas ajouter un conteneur de plus (issue #115).
|
||||
|
||||
@@ -22,11 +22,9 @@ services:
|
||||
ports: !override
|
||||
- "127.0.0.1:${MAILPIT_UI_PORT:-8025}:8025"
|
||||
|
||||
airflow-webserver:
|
||||
airflow-apiserver:
|
||||
ports: !override
|
||||
- "127.0.0.1:${AIRFLOW_PORT:-8080}:8080"
|
||||
environment:
|
||||
AIRFLOW__WEBSERVER__WORKERS: ${AIRFLOW_WEBSERVER_WORKERS:-2}
|
||||
|
||||
backend:
|
||||
ports: !reset null
|
||||
@@ -51,7 +49,7 @@ services:
|
||||
ports: !reset null
|
||||
|
||||
proxy:
|
||||
image: nginx:1.28-alpine
|
||||
image: nginx:1.31-alpine
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
|
||||
+31
-13
@@ -5,9 +5,9 @@
|
||||
|
||||
name: enervision
|
||||
|
||||
# Piege : LocalExecutor fait tourner les taches comme sous-processus du scheduler, jamais du
|
||||
# webserver. `airflow_ml_state` (modele entraine, magasin MLflow) n'a donc besoin d'etre monte
|
||||
# que sur `airflow-scheduler` en pratique, mais reste partage avec le webserver pour que ce
|
||||
# Piege : LocalExecutor fait tourner les taches comme sous-processus du scheduler, jamais de
|
||||
# l'api-server. `airflow_ml_state` (modele entraine, magasin MLflow) n'a donc besoin d'etre monte
|
||||
# que sur `airflow-scheduler` en pratique, mais reste partage avec l'api-server pour que ce
|
||||
# dernier puisse au besoin l'inspecter sans en devenir dependant.
|
||||
x-airflow-common: &airflow-common
|
||||
build:
|
||||
@@ -19,9 +19,16 @@ x-airflow-common: &airflow-common
|
||||
# Piege : pas de `:?` sur les secrets Airflow. Compose interpole le fichier entier avant de
|
||||
# filtrer les services : une variable requise manquante casserait aussi `make db-up`,
|
||||
# `make dev`... pour quiconque n'a pas encore complete son `.env`. Le refus est porte par
|
||||
# `airflow-init` (ci-dessous), dont `webserver` et `scheduler` dependent.
|
||||
# `airflow-init` (ci-dessous), dont `api-server`, `dag-processor` et `scheduler` dependent.
|
||||
AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:-}
|
||||
AIRFLOW__WEBSERVER__SECRET_KEY: ${AIRFLOW_WEBSERVER_SECRET_KEY:-}
|
||||
AIRFLOW__API__SECRET_KEY: ${AIRFLOW_API_SECRET_KEY:-}
|
||||
# Signe les jetons entre scheduler, tâches et api-server. Conteneurs distincts : un secret
|
||||
# généré au démarrage ne serait pas partagé, il doit venir du .env.
|
||||
AIRFLOW__API_AUTH__JWT_SECRET: ${AIRFLOW_JWT_SECRET:-}
|
||||
AIRFLOW__CORE__EXECUTION_API_SERVER_URL: http://airflow-apiserver:8080/execution/
|
||||
# FabAuthManager plutôt que le SimpleAuthManager par défaut d'Airflow 3 : seul le provider
|
||||
# FAB sait créer le compte admin que `airflow-init` pose via `_AIRFLOW_WWW_USER_*`.
|
||||
AIRFLOW__CORE__AUTH_MANAGER: airflow.providers.fab.auth_manager.fab_auth_manager.FabAuthManager
|
||||
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/airflow
|
||||
# Role `enervision_ml` dedie pas encore provisionne (dette assumee, cf. ADR 0003) :
|
||||
# memes identifiants que le backend en attendant.
|
||||
@@ -111,11 +118,11 @@ services:
|
||||
# Conteneur unique, jamais redemarre. La migration et la creation du premier compte sont
|
||||
# portees par l'entrypoint de l'image (`_AIRFLOW_DB_MIGRATE`, `_AIRFLOW_WWW_USER_*`), qui porte
|
||||
# aussi leur code de sortie : une migration ratee (ex. base `airflow` absente sur un volume
|
||||
# `pgdata` deja peuple) fait echouer ce service, et `webserver`/`scheduler`, qui attendent son
|
||||
# succes, ne demarrent pas sur une base non migree. Le mot de passe passe par l'environnement,
|
||||
# jamais par `argv` (ni `ps`, ni `docker compose config`).
|
||||
# `pgdata` deja peuple) fait echouer ce service, et api-server, dag-processor et scheduler,
|
||||
# qui attendent son succes, ne demarrent pas sur une base non migree. Le mot de passe passe
|
||||
# par l'environnement, jamais par `argv` (ni `ps`, ni `docker compose config`).
|
||||
# Sans mot de passe, l'entrypoint refuse lui-meme de creer le compte ; la commande ci-dessous
|
||||
# refuse en plus les deux cles de chiffrement vides.
|
||||
# refuse en plus les cles et secrets vides.
|
||||
airflow-init:
|
||||
<<: *airflow-common
|
||||
restart: "no"
|
||||
@@ -135,13 +142,14 @@ services:
|
||||
- |
|
||||
set -euo pipefail
|
||||
: "$${AIRFLOW__CORE__FERNET_KEY:?AIRFLOW_FERNET_KEY manquant dans .env}"
|
||||
: "$${AIRFLOW__WEBSERVER__SECRET_KEY:?AIRFLOW_WEBSERVER_SECRET_KEY manquant dans .env}"
|
||||
: "$${AIRFLOW__API__SECRET_KEY:?AIRFLOW_API_SECRET_KEY manquant dans .env}"
|
||||
: "$${AIRFLOW__API_AUTH__JWT_SECRET:?AIRFLOW_JWT_SECRET manquant dans .env}"
|
||||
: "$${APP_SECRET_KEY:?AIRFLOW_APP_SECRET_KEY manquant dans .env}"
|
||||
exec airflow version
|
||||
|
||||
airflow-webserver:
|
||||
airflow-apiserver:
|
||||
<<: *airflow-common
|
||||
command: webserver
|
||||
command: api-server
|
||||
ports:
|
||||
- "${AIRFLOW_PORT:-8080}:8080"
|
||||
depends_on:
|
||||
@@ -150,7 +158,7 @@ services:
|
||||
airflow-init:
|
||||
condition: service_completed_successfully
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "http://localhost:8080/health"]
|
||||
test: ["CMD", "curl", "--fail", "http://localhost:8080/api/v2/monitor/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
@@ -165,6 +173,16 @@ services:
|
||||
airflow-init:
|
||||
condition: service_completed_successfully
|
||||
|
||||
# Obligatoire depuis Airflow 3 : le scheduler ne parse plus les fichiers de dags/ lui-même.
|
||||
airflow-dag-processor:
|
||||
<<: *airflow-common
|
||||
command: dag-processor
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
airflow-init:
|
||||
condition: service_completed_successfully
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
airflow_logs:
|
||||
|
||||
@@ -16,3 +16,4 @@
|
||||
| [0007](adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | Terminaison TLS par un reverse proxy Nginx, en Docker Compose |
|
||||
| [0008](adr/0008-airflow-execute-le-code-du-backend.md) | Airflow exécute le code du backend en sous-processus, dans son propre environnement |
|
||||
| [0009](adr/0009-deux-environnements-compose-sur-la-vm-eni.md) | Deux environnements sur la VM ENI, un projet Compose chacun, déployés par un runner auto-hébergé |
|
||||
| [0010](adr/0010-terraform-provisionne-github-actions-deploie.md) | Terraform provisionne la machine, GitHub Actions déploie l'application |
|
||||
|
||||
@@ -63,8 +63,9 @@ secret.
|
||||
|
||||
- Deux TimescaleDB sur une machine de 8 Go : sans réglage, chacune se réserverait 25 % de la
|
||||
RAM au premier démarrage. L'overlay fixe `TS_TUNE_MEMORY` à 2 Go et `TS_TUNE_NUM_CPUS` à 2 par
|
||||
base, et 2 workers gunicorn par webserver Airflow. La montée à 32 Go prévue par les
|
||||
consignes est à demander.
|
||||
base. Airflow 3 n'a rien à régler de ce côté : son api-server lance un seul worker par défaut,
|
||||
là où le webserver d'Airflow 2 en lançait quatre. La montée à 32 Go prévue par les consignes
|
||||
est à demander.
|
||||
- Un runner auto-hébergé sur un dépôt public exécute le code qu'on lui envoie. `deploy.yml` ne
|
||||
se déclenche jamais sur `pull_request`, le runner tourne sous un utilisateur dédié, et le
|
||||
dépôt doit exiger une approbation pour les workflows des PR externes.
|
||||
@@ -75,6 +76,7 @@ secret.
|
||||
à chaque push : plusieurs minutes par déploiement, acceptable pour la cadence du projet.
|
||||
- `environments/prod` de Terraform reste vide. Le provisionnement de la machine est porté par
|
||||
`scripts/provision-host.sh`, que Terraform pourra appeler par `remote-exec` le jour où une
|
||||
racine visant la VM existera.
|
||||
racine visant la VM existera. Cette racine existe depuis l'[ADR 0010](0010-terraform-provisionne-github-actions-deploie.md),
|
||||
sous le nom `environments/vm-eni`, et `environments/prod` a disparu avec elle.
|
||||
- L'image frontend quitte `dhi.io/nginx`, registre authentifié dont personne n'a l'accès, pour
|
||||
`nginx:1.28-alpine`, la même image que le proxy. Elle n'avait jamais été construite.
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# 0010 - Terraform provisionne la machine, GitHub Actions déploie l'application
|
||||
|
||||
- Statut : accepté
|
||||
- Date : 2026-09-22
|
||||
|
||||
## Contexte
|
||||
|
||||
L'[ADR 0009](0009-deux-environnements-compose-sur-la-vm-eni.md) a posé la livraison : deux
|
||||
projets Compose sur la VM ENI, alignés sur `dev` et sur `main` par un runner auto-hébergé. Elle
|
||||
ne dit pas qui prépare la machine. C'est `scripts/provision-host.sh`, lancé à la main en SSH.
|
||||
|
||||
La grille d'évaluation attend en C22 que l'infrastructure soit « provisionnée via du code
|
||||
(Terraform, Ansible…) ». Le seul Terraform du dépôt installe un cluster k3s que rien ne
|
||||
consomme, qui n'a jamais été appliqué, et dont la racine ne passait même pas `terraform init`
|
||||
depuis que Terraform refuse les provisioners `destroy` dont la connexion lit autre chose que
|
||||
`self`. Sa racine s'appelait `environments/dev`, nom qui laissait croire à un environnement
|
||||
applicatif alors que les deux environnements réels sont `rec` et `prod`, sur la même machine.
|
||||
|
||||
La branche `feat/deploy` (PR #141) proposait la réponse inverse : Terraform construit les
|
||||
images, lance les conteneurs et copie les sources par SSH. La revue a relevé deux racines sur
|
||||
trois qui ne passent pas `terraform validate`, le mot de passe SSH écrit en clair dans le state,
|
||||
un backend lancé sans base ni variables d'environnement, et trois architectures différentes pour
|
||||
trois environnements.
|
||||
|
||||
## Décision
|
||||
|
||||
**Terraform provisionne la machine, GitHub Actions déploie l'application.** La frontière est
|
||||
nette et vérifiable : `infra/terraform/environments/vm-eni` installe Docker et le plugin
|
||||
Compose, exécute `scripts/provision-host.sh`, enregistre le runner. Il ne construit aucune
|
||||
image, ne lance aucun conteneur, et un `apply` n'interrompt pas la stack qui tourne.
|
||||
|
||||
**Le déploiement continu ne change pas.** `deploy.yml` reste le seul chemin de livraison : push
|
||||
sur `dev` ou `main`, alignement du clone, `make stack-up`, sonde `/api/v1/health/ready`.
|
||||
|
||||
**Le Bash reste la mécanique, Terraform devient le point d'entrée.** `provision-host.sh` connaît
|
||||
les deux environnements, leurs ports décalés, leurs secrets et leurs certificats. Le réécrire en
|
||||
HCL créerait une seconde source de vérité qui divergerait au premier changement de port.
|
||||
|
||||
**Aucun secret dans le state.** Authentification SSH par clé seulement, pas de variable de mot
|
||||
de passe. Le jeton d'enregistrement du runner est une variable `sensitive` fournie à l'`apply`,
|
||||
jamais un `trigger` : les `triggers` sont la seule partie d'un `null_resource` que Terraform
|
||||
persiste.
|
||||
|
||||
**Les racines portent le nom de ce qu'elles provisionnent**, pas d'un environnement applicatif :
|
||||
`vm-eni` pour la machine, `k3s-cible` pour le cluster resté en cible. `environments/prod`,
|
||||
dossier vide, disparaît.
|
||||
|
||||
## Alternatives écartées
|
||||
|
||||
- **Ansible à la place du Bash** : plus idiomatique pour de la configuration de machine, et le
|
||||
jury le reconnaîtrait immédiatement comme de l'IaC. Mais c'est un outil de plus à installer et
|
||||
à faire tourner, pour réécrire un script qui fonctionne, à trois jours du gel technique.
|
||||
- **Provisioners applicatifs de `feat/deploy`** : voir la revue sur #141. Terraform y devenait un
|
||||
orchestrateur concurrent de Compose, sans base de données ni migrations.
|
||||
- **Terraform appelle aussi `make stack-up`** : le premier démarrage serait plus court d'une
|
||||
commande, mais Terraform se mettrait à porter la livraison, que le runner rejoue à chaque
|
||||
push. Deux chemins pour le même acte, c'est précisément ce que #141 montre qu'il ne faut pas.
|
||||
- **k3s tout de suite** : le cluster serait vide, sans manifeste, sans registre et sans stockage
|
||||
persistant. Le module reste, documenté comme cible.
|
||||
- **State Terraform distant** : un seul opérateur, pas d'exécution concurrente. Le backend local
|
||||
suffit, comme pour `k3s-cible`.
|
||||
|
||||
## Conséquences
|
||||
|
||||
- Le premier `apply` exige un jeton d'enregistrement du runner, valable une heure et pour une
|
||||
seule inscription, que seul un administrateur du dépôt peut créer. L'`apply` n'est donc pas
|
||||
rejouable sans intervention humaine, ce qui est acceptable : il ne se joue qu'à l'installation.
|
||||
- L'utilisateur propriétaire de `/srv/enervision` doit exister sur la machine avant l'`apply`.
|
||||
Terraform vérifie et échoue tôt plutôt que de le créer : décider d'un compte système est une
|
||||
décision d'administration, pas un effet de bord de déploiement.
|
||||
- Terraform ne sait rien de l'état de la stack. `terraform plan` ne dira jamais que la recette
|
||||
est tombée ; c'est la sonde de `deploy.yml` qui le dit.
|
||||
- Pas de provisioner `destroy` sur le runner : il imposerait de mettre le chemin de la clé SSH
|
||||
dans le state, et `svc.sh uninstall` ne désinscrit pas le runner côté GitHub. Le retrait reste
|
||||
manuel, depuis les paramètres du dépôt.
|
||||
- C22 cesse de reposer sur `docker-compose.prod.yml` seul. C23 reste porté par les scripts, que
|
||||
Terraform appelle désormais au lieu de les remplacer.
|
||||
@@ -87,10 +87,10 @@ collecteur ne vient le lire.
|
||||
| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, authentification complète (garde de route, intercepteur de jeton), cinq services HTTP, graphiques Chart.js. `stats`/`alerts` sur fixtures, `predictions` branché sur l'API réelle |
|
||||
| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) |
|
||||
| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`, orchestré par Airflow (`ml_train`/`ml_score`). Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../ML-START.md). Surveillance de dérive (EC06, #44/#45) pas encore construite |
|
||||
| Infra | Docker Compose, Nginx, Terraform, k3s single-node | `infra`, `docker-compose.prod.yml` | `En cours` | Reverse proxy et overlay de déploiement écrits et validés, jamais lancés sur le serveur ([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)). Module d'installation k3s jamais appliqué, aucune ressource Kubernetes déclarée |
|
||||
| Infra | Docker Compose, Nginx, Terraform, k3s single-node | `infra`, `docker-compose.prod.yml` | `En cours` | Reverse proxy et overlay de déploiement écrits et validés, jamais lancés sur le serveur ([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)). Provisionnement de la VM par Terraform, qui installe Docker, prépare les deux environnements et enregistre le runner, jamais appliqué ([ADR 0010](../adr/0010-terraform-provisionne-github-actions-deploie.md)). Module d'installation k3s jamais appliqué, aucune ressource Kubernetes déclarée |
|
||||
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
|
||||
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Quatre DAGs en sous-processus `uv run` : `ml_train`, `ml_score`, `alertes` et `historical_import`. Le DAG historique orchestre `app.etl.historical_import` et charge `dataset`, `site` et `reading`. L'orchestration API Mock reste à compléter dans #15 |
|
||||
| CI/CD | GitHub Actions | `.github/workflows` | `En cours` | 6 workflows, 18 jobs : lint, typage, tests avec seuil de couverture bloquant, tests d'intégration sur TimescaleDB réel, audit de dépendances, SAST Bandit, quality gate SonarCloud, intégrité des DAGs Airflow. Déploiement continu vers la VM ENI écrit par `deploy.yml`, `dev` en recette et `main` en production après approbation ([ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md)), mais jamais exécuté : la machine n'est pas provisionnée et le runner n'y est pas enregistré. Détail dans [50-cicd.md](50-cicd.md) |
|
||||
| CI/CD | GitHub Actions | `.github/workflows` | `En cours` | 7 workflows, 19 jobs : lint, typage, tests avec seuil de couverture bloquant, tests d'intégration sur TimescaleDB réel, audit de dépendances, SAST Bandit, quality gate SonarCloud, intégrité des DAGs Airflow, formatage et validation du Terraform. Déploiement continu vers la VM ENI écrit par `deploy.yml`, `dev` en recette et `main` en production après approbation ([ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md)), mais jamais exécuté : la machine n'est pas provisionnée et le runner n'y est pas enregistré. Détail dans [50-cicd.md](50-cicd.md) |
|
||||
|
||||
## Flux bout en bout
|
||||
|
||||
@@ -189,3 +189,4 @@ Elles vivent dans `../adr/`, pas ici.
|
||||
| [0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | Terminaison TLS par un reverse proxy Nginx, en Docker Compose |
|
||||
| [0008](../adr/0008-airflow-execute-le-code-du-backend.md) | Airflow exécute le code du backend en sous-processus, dans son propre environnement |
|
||||
| [0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md) | Deux environnements sur la VM ENI, un projet Compose chacun, déployés par un runner auto-hébergé |
|
||||
| [0010](../adr/0010-terraform-provisionne-github-actions-deploie.md) | Terraform provisionne la machine, GitHub Actions déploie l'application |
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
# Infrastructure
|
||||
|
||||
Trois topologies coexistent et ne servent pas la même chose. Ce document dit laquelle vaut dans
|
||||
quel contexte, quelles décisions sont arrêtées, et ce qui manque encore entre elles.
|
||||
Plusieurs topologies coexistent et ne servent pas la même chose. Ce document dit laquelle vaut
|
||||
dans quel contexte, quelles décisions sont arrêtées, et ce qui manque encore entre elles.
|
||||
|
||||
| Topologie | Sert à | Statut |
|
||||
|---|---|---|
|
||||
| Docker Compose | Développer et recetter sur le poste | `Fait` |
|
||||
| Docker Compose plus reverse proxy | Déployer sur la machine on-premise | `Fait` |
|
||||
| Deux projets Compose sur la VM ENI, recette et production | Déploiement continu depuis GitHub | `En cours` |
|
||||
| Provisionnement Terraform de la VM | Préparer la machine et enregistrer le runner | `En cours` |
|
||||
| k3s single-node | Cible à terme | `En cours` |
|
||||
|
||||
## Poste de développement
|
||||
@@ -49,20 +50,28 @@ Trois pièges sont documentés en tête du `docker-compose.yml`, ils ne se devin
|
||||
- `db/init` est monté **fichier par fichier**. Monter le dossier masquerait les scripts d'init de
|
||||
l'image, dont `timescaledb-tune`. Ajouter un fichier dans `db/init/` impose donc une ligne dans
|
||||
le compose. Voir [`db/README.md`](../../db/README.md).
|
||||
- `LocalExecutor` exécute les tâches comme sous-processus du **scheduler**, jamais du webserver :
|
||||
- `LocalExecutor` exécute les tâches comme sous-processus du **scheduler**, jamais de l'api-server :
|
||||
c'est le scheduler qui a besoin du volume `airflow_ml_state` (modèle, magasin MLflow).
|
||||
|
||||
### Airflow (issues #115, #116 et #119)
|
||||
|
||||
Trois services, `docker compose profiles` non utilisés (démarrage explicite via `make
|
||||
Quatre services (Airflow 3.3), `docker compose profiles` non utilisés (démarrage explicite via `make
|
||||
airflow-up`, pas dans `make dev`) :
|
||||
|
||||
| Service | Rôle | Points notables |
|
||||
|---|---|---|
|
||||
| `airflow-init` | Migre la base de métadonnées, crée le compte admin | Conteneur jetable (`restart: "no"`), ne redémarre jamais. `webserver`/`scheduler` attendent qu'il se termine avec succès |
|
||||
| `airflow-webserver` | UI, port `8080` | `LocalExecutor` : n'exécute aucune tâche lui-même |
|
||||
| `airflow-init` | Migre la base de métadonnées, crée le compte admin | Conteneur jetable (`restart: "no"`), ne redémarre jamais. `api-server`, `dag-processor` et `scheduler` attendent qu'il se termine avec succès |
|
||||
| `airflow-apiserver` | UI et API REST (`/api/v2`), port `8080` | `LocalExecutor` : n'exécute aucune tâche lui-même. Sert aussi l'Execution API que les tâches appellent, d'où le secret JWT partagé |
|
||||
| `airflow-dag-processor` | Parse `dags/` et publie les DAGs sérialisés | Composant à part entière depuis Airflow 3 : le scheduler ne lit plus les fichiers de DAG |
|
||||
| `airflow-scheduler` | Planifie et **exécute** les tâches (`LocalExecutor`) | Les DAGs y tournent en sous-processus (`uv run --no-sync python -m ...`), c'est lui qui a besoin du volume `airflow_ml_state` |
|
||||
|
||||
Airflow 3 impose deux choses que le compose reflète : les tâches ne touchent plus la base de
|
||||
métadonnées et passent par l'Execution API de l'`api-server`, avec un jeton signé par
|
||||
`AIRFLOW_JWT_SECRET` (secret partagé entre conteneurs, jamais celui généré au démarrage) ; et
|
||||
l'authentification par défaut (`SimpleAuthManager`) ne sait pas créer de compte, d'où le
|
||||
`FabAuthManager` qui garde le compte admin posé par `airflow-init`. Pas de `triggerer` : aucun
|
||||
opérateur déférable dans les DAGs.
|
||||
|
||||
Construits depuis `etl/airflow/Dockerfile`, contexte `.` (racine du repo, pas `etl/airflow/`) :
|
||||
l'image doit pouvoir `COPY` les sources de `ml/` **et** de `apps/backend/` pour se synchroniser
|
||||
deux environnements Python **3.14** (`/opt/ml/.venv` et `/opt/backend/.venv`, `uv sync --locked` à
|
||||
@@ -102,14 +111,14 @@ rend contraignant.
|
||||
`airflow-init` s'appuie sur l'entrypoint de l'image (`_AIRFLOW_DB_MIGRATE`,
|
||||
`_AIRFLOW_WWW_USER_*`) plutôt que sur un script maison : l'entrypoint porte le code de sortie, une
|
||||
migration ratée (typiquement la base `airflow` absente, cf. ci-dessous) fait échouer le service et
|
||||
`webserver`/`scheduler` ne démarrent pas sur une base non migrée. Le mot de passe du compte admin
|
||||
`api-server`, `dag-processor` et `scheduler` ne démarrent pas sur une base non migrée. Le mot de passe du compte admin
|
||||
passe par l'environnement, jamais par `argv` (ni `ps`, ni `docker compose config`).
|
||||
|
||||
Les variables `AIRFLOW_*` ne sont volontairement pas en `${VAR:?}` : Compose interpole le fichier
|
||||
entier avant de filtrer les services, une variable requise manquante casserait `make db-up`,
|
||||
`make dev`... pour tout poste dont le `.env` est antérieur. Elles valent `${VAR:-}` et c'est
|
||||
`airflow-init` qui refuse de démarrer (clé Fernet, clé Flask, mot de passe ou
|
||||
`AIRFLOW_APP_SECRET_KEY` vides).
|
||||
`airflow-init` qui refuse de démarrer (clé Fernet, clé de session de l'API, secret JWT, mot de
|
||||
passe ou `AIRFLOW_APP_SECRET_KEY` vides).
|
||||
|
||||
Le conteneur reçoit deux variables du backend en plus de `ML_DATABASE_URL` : `DATABASE_URL`, en
|
||||
dialecte asyncpg, et `APP_SECRET_KEY`, alimentée par `AIRFLOW_APP_SECRET_KEY`. Cette dernière est
|
||||
@@ -201,10 +210,38 @@ Nginx renvoie vers `https://$host` sans port, c'est-à-dire vers la production.
|
||||
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`.
|
||||
|
||||
### Provisionnement de la machine
|
||||
|
||||
Statut : `En cours`. Décision et frontière dans
|
||||
l'[ADR 0010](../adr/0010-terraform-provisionne-github-actions-deploie.md) : **Terraform
|
||||
provisionne la machine, GitHub Actions déploie l'application**. La racine
|
||||
`infra/terraform/environments/vm-eni/` fait trois choses, et rien d'autre.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant TF as terraform apply
|
||||
participant VM as VM eadl-2025-nantes-g3
|
||||
participant GH as GitHub
|
||||
|
||||
TF->>VM: SSH, get.docker.com puis docker compose version
|
||||
TF->>VM: copie et exécute scripts/provision-host.sh
|
||||
VM->>VM: deux clones, deux .env, deux certificats
|
||||
TF->>VM: installe actions-runner, config.sh, svc.sh
|
||||
VM->>GH: le runner s'enregistre avec le label eni-g3
|
||||
```
|
||||
|
||||
Aucune image n'y est construite, aucun conteneur lancé : un `apply` n'interrompt pas la stack qui
|
||||
tourne. Le premier démarrage reste manuel, `make stack-up` dans chaque dossier ; les suivants
|
||||
sont joués par le runner à chaque push. Terraform ne sait rien de l'état de la stack, c'est la
|
||||
sonde de `deploy.yml` qui le dit.
|
||||
|
||||
Le jeton d'enregistrement du runner est valable une heure et ne vaut que pour une inscription :
|
||||
l'`apply` n'est pas rejouable sans qu'un administrateur du dépôt en crée un nouveau.
|
||||
|
||||
## Cible à terme, k3s
|
||||
|
||||
Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster. Il n'a jamais
|
||||
été appliqué.
|
||||
Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster, depuis la
|
||||
racine `infra/terraform/environments/k3s-cible/`. Il n'a jamais été appliqué.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
@@ -252,11 +289,13 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de
|
||||
| `k3s_version` obligatoire, valeur vide refusée | Sans épinglage, `get.k3s.io` installe la dernière version à chaque exécution : le déploiement cesse d'être reproductible | `validation` dans `modules/k3s/variables.tf` |
|
||||
| Traefik désactivé | Le choix d'ingress reste ouvert, on ne veut pas en subir un par défaut | `k3s_disable_components`, défaut `["traefik"]` |
|
||||
| Kubeconfig laissé en `600/root`, lu par `sudo` | `--write-kubeconfig-mode 644` exposerait `cluster-admin` à tout utilisateur local de la machine | Commentaire et `fetch_kubeconfig` dans `modules/k3s/main.tf` |
|
||||
| State Terraform en backend `local` | Un seul opérateur, pas d'exécution concurrente, pas de dépendance à un stockage distant | `environments/dev/versions.tf` |
|
||||
| State Terraform en backend `local` | Un seul opérateur, pas d'exécution concurrente, pas de dépendance à un stockage distant | `versions.tf` de chaque racine |
|
||||
| `.terraform.lock.hcl` versionné | Fige les versions de provider entre contributeurs et future CI | Commentaire dans `.gitignore` |
|
||||
| `*.tfvars` ignoré, `*.tfvars.example` versionné | Les tfvars portent l'adresse du serveur et le chemin de la clé | `.gitignore` |
|
||||
| Désinstallation gérée au `destroy` | `k3s-uninstall.sh` en `on_failure = continue` : un serveur injoignable ne bloque pas le `destroy` | `modules/k3s/main.tf` |
|
||||
| Deux racines, `dev` et `prod` | Séparation des états et des variables par environnement | `environments/` |
|
||||
| Une racine Terraform par machine provisionnée, nommée d'après elle | `environments/dev` laissait croire à un environnement applicatif, alors que `rec` et `prod` vivent sur la même machine et ne sont pas provisionnés par Terraform | `environments/vm-eni`, `environments/k3s-cible` |
|
||||
| Terraform provisionne, GitHub Actions déploie | Deux chemins pour le même acte de livraison, c'est ce que la revue de #141 relève sur la VM | [ADR 0010](../adr/0010-terraform-provisionne-github-actions-deploie.md) |
|
||||
| Connexion SSH par clé, jamais par mot de passe | Une variable de mot de passe finit en clair dans le state, ou dans les `triggers` qui y sont persistés | `environments/vm-eni/variables.tf`, `modules/k3s/main.tf` |
|
||||
| Terminaison TLS par un reverse proxy Nginx en Compose | L'ingress k3s supposait un registre et des manifestes qui n'existent pas, à quatre jours du rendu | `docker-compose.prod.yml`, [ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) |
|
||||
| Certificat auto-signé par défaut, chemin ACME câblé | Aucun domaine public ne résout vers la machine : le défi HTTP-01 ne peut pas aboutir | `scripts/tls-selfsigned.sh`, `infra/proxy/acme-deploy-hook.sh` |
|
||||
| Un projet Compose par environnement, sur la même machine | Une seule VM, et l'isolation par nom de projet ne demande ni cluster ni registre | `.env` de chaque dossier, [ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md) |
|
||||
@@ -277,7 +316,7 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de
|
||||
| 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` |
|
||||
| Base de métadonnées Airflow | `airflow` | Créée par `db/init/120-airflow-database.sql`, même conteneur `db` |
|
||||
| Webserver Airflow | `8080` | `make airflow-up`. Scheduler et webserver ne publient que ce port ; les tâches (`LocalExecutor`) tournent côté scheduler, sans port propre |
|
||||
| API server Airflow | `8080` | `make airflow-up`. Api-server, scheduler et dag-processor ne publient que ce port ; les tâches (`LocalExecutor`) tournent côté scheduler, sans port propre |
|
||||
|
||||
## Le trou vers k3s
|
||||
|
||||
@@ -296,4 +335,3 @@ question à trancher, avant toute ressource Kubernetes.
|
||||
- **Quel stockage persistant** côté Kubernetes pour PostgreSQL, et si la base tourne dans le
|
||||
cluster ou à côté.
|
||||
- **Quelle stratégie de sauvegarde et de restauration** des données de mesure.
|
||||
- **Que devient `environments/prod/`**, aujourd'hui réduit à un `.gitkeep`.
|
||||
|
||||
@@ -331,8 +331,10 @@ pas prise :
|
||||
| `license_info` | Aucune licence n'est choisie |
|
||||
| `contact` | Aucun canal de support n'existe |
|
||||
|
||||
Deux schémas de sécurité sont déclarés : `Jeton d'accès` pour le porteur JWT, et
|
||||
`Cookie de rafraîchissement` pour `/auth/refresh` et `/auth/logout`. **Le second est purement
|
||||
Deux schémas de sécurité sont déclarés : `JetonAcces` pour le porteur JWT, et
|
||||
`CookieRafraichissement` pour `/auth/refresh` et `/auth/logout`, des noms ASCII délibérés (issue
|
||||
#41 : un outillage tiers comme ZAP peut mal analyser un nom de schéma accentué dans le contrat).
|
||||
**Le second est purement
|
||||
documentaire** : son `auto_error=False` garantit qu'il ne décide d'aucun refus. Le passer à vrai
|
||||
ferait répondre 403 avant d'atteindre `lit_le_cookie()`, et `/auth/refresh` cesserait de rendre le
|
||||
401 sur lequel le frontend déclenche sa déconnexion.
|
||||
|
||||
+138
-11
@@ -16,6 +16,12 @@ basculer sur `Fait` au premier déploiement vert. Sa limite, nommée ici plutôt
|
||||
soutenance : les images sont construites sur la machine à chaque déploiement, aucun artefact
|
||||
n'est publié puis promu d'un environnement à l'autre.
|
||||
|
||||
Ce que ce workflow ne fait pas, et ne fera pas : préparer la machine. Installation de Docker,
|
||||
clones, `.env`, certificats et enregistrement du runner sont provisionnés par
|
||||
`infra/terraform/environments/vm-eni`
|
||||
([ADR 0010](../adr/0010-terraform-provisionne-github-actions-deploie.md)). Terraform provisionne,
|
||||
GitHub Actions déploie ; aucun des deux ne fait le travail de l'autre.
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
```mermaid
|
||||
@@ -45,6 +51,10 @@ flowchart TB
|
||||
ab["image<br/>construction de l'image"]
|
||||
end
|
||||
|
||||
subgraph infw["Infra · infra.yml"]
|
||||
it["terraform<br/>fmt -check, init et validate par racine"]
|
||||
end
|
||||
|
||||
subgraph sq["SonarQube · sonarqube.yml"]
|
||||
sb1["build-front / test-front"]
|
||||
sb2["build-back / test-back"]
|
||||
@@ -57,22 +67,36 @@ flowchart TB
|
||||
push --> fd
|
||||
push --> mv & ms
|
||||
push --> av & ab
|
||||
push --> sb1 & sb2 --> sscan
|
||||
push --> it
|
||||
push --> sb1 & sb2 & sb3 --> sscan
|
||||
|
||||
subgraph cd["Déploiement · deploy.yml"]
|
||||
dep["deploy<br/>runner eni-g3, environnement rec ou prod"]
|
||||
end
|
||||
|
||||
push -->|"push sur dev ou main"| dep
|
||||
|
||||
planifie["chaque lundi 3h UTC,<br/>ou à la main"]
|
||||
subgraph dastw["DAST · dast.yml"]
|
||||
zscan["zap<br/>seed + scan actif OWASP ZAP"]
|
||||
end
|
||||
|
||||
planifie --> zscan
|
||||
push -->|"PR sur dast.yml<br/>ou dast-token.sh"| zscan
|
||||
```
|
||||
|
||||
## Déclenchement
|
||||
|
||||
Les cinq workflows se déclenchent sur `push` **et** sur `pull_request`, filtrés par **chemin** :
|
||||
`backend.yml` sur `apps/backend/**`, `frontend.yml` sur `apps/frontend/**`, `ml.yml` sur `ml/**`,
|
||||
`airflow.yml` sur `etl/airflow/**` **plus des chemins de `ml/` et de `apps/backend/`**, chacun
|
||||
incluant son propre fichier de workflow dans le filtre pour qu'une modification du pipeline
|
||||
déclenche le pipeline.
|
||||
Les six workflows hébergés par GitHub qui vérifient le code se déclenchent sur `push` **et** sur
|
||||
`pull_request`, filtrés par **chemin** : `backend.yml` sur `apps/backend/**`, `frontend.yml` sur
|
||||
`apps/frontend/**`, `ml.yml` sur `ml/**`, `infra.yml` sur `infra/terraform/**`, `airflow.yml` sur
|
||||
`etl/airflow/**` **plus des chemins de `ml/` et de `apps/backend/`**, chacun incluant son propre
|
||||
fichier de workflow dans le filtre pour qu'une modification du pipeline déclenche le pipeline.
|
||||
|
||||
`dast.yml` s'en écarte volontairement (détail dans sa propre section plus bas) : aucun
|
||||
déclenchement sur `push`, seulement `workflow_dispatch`, une planification hebdomadaire, et
|
||||
`pull_request` restreint à ses deux seuls fichiers. Un scan actif est trop long pour tourner à
|
||||
chaque commit.
|
||||
|
||||
Le filtre d'`airflow.yml` mérite un mot : il inclut `ml/pyproject.toml`, `ml/uv.lock`,
|
||||
`ml/enervision_ml/**`, `apps/backend/pyproject.toml`, `apps/backend/uv.lock` et
|
||||
@@ -90,13 +114,15 @@ rien changer), mais ce serait à borner sur un dépôt à forte fréquence de pu
|
||||
`backend.yml`, `ml.yml` et `airflow.yml` déclarent en plus un groupe de concurrence par référence
|
||||
git avec `cancel-in-progress`, ce qui annule un run devenu obsolète par un push plus récent.
|
||||
|
||||
**Piège de version** : `etl/airflow` tourne en **Python 3.12** et non 3.14, parce qu'Airflow 2.10
|
||||
ne supporte pas encore 3.14. Le 3.14 du module ML ne vit, dans ce contexte, que dans l'image
|
||||
Docker et son propre environnement.
|
||||
**Piège de version** : `etl/airflow` tourne en **Python 3.12** et non 3.14 : c'est l'interpréteur
|
||||
de l'image `apache/airflow:3.3.2-python3.12` retenue, et les tests d'intégrité doivent tourner sur
|
||||
le même. Le 3.14 du module ML ne vit, dans ce contexte, que dans l'image Docker et son propre
|
||||
environnement.
|
||||
|
||||
## Déploiement
|
||||
|
||||
`deploy.yml` est le sixième workflow, et le seul qui ne tourne pas chez GitHub : il s'exécute sur
|
||||
`deploy.yml` est le huitième workflow (`backend`, `frontend`, `ml`, `infra`, `airflow`,
|
||||
`sonarqube`, `dast`, plus lui-même), et le seul qui ne tourne pas chez GitHub : il s'exécute sur
|
||||
un runner auto-hébergé installé sur la VM ENI, label `eni-g3`, parce que les runners hébergés ne
|
||||
joignent pas une adresse privée d'école. Le runner se connecte en sortie vers GitHub, aucun port
|
||||
entrant n'est ouvert.
|
||||
@@ -150,6 +176,7 @@ dans [10-infra.md](10-infra.md).
|
||||
| Build `npm run build` | frontend | compilation | Bloque |
|
||||
| Intégrité des DAGs | airflow | chargement des DAGs sans erreur d'import | Bloque |
|
||||
| Construction de l'image Airflow | airflow | `docker build` de `etl/airflow/Dockerfile` | Bloque |
|
||||
| Formatage et validité Terraform | infra | `fmt -check -recursive`, puis `init` et `validate` par racine | Bloque |
|
||||
|
||||
Deux seuils portent une décision qu'il faut savoir défendre :
|
||||
|
||||
@@ -241,12 +268,112 @@ Ils ne transitent ni par git ni par GitHub, et le runner, qui travaille dans ce
|
||||
à recevoir. Le revers : ils ne sont sauvegardés nulle part ailleurs. Un `.env` perdu se
|
||||
régénère, ce qui invalide les sessions et les connexions chiffrées par Airflow.
|
||||
|
||||
## Scan DAST (OWASP ZAP)
|
||||
|
||||
Statut : `En cours`. Le workflow `dast.yml` attaque l'API **en fonctionnement**, ce que ni Bandit,
|
||||
ni `pip-audit`, ni Sonar ne font. Il se lance à la main (`workflow_dispatch`), chaque lundi à 3h
|
||||
UTC, et sur une PR qui modifie le scan lui-même. Pas à chaque PR : un scan actif dure plusieurs
|
||||
minutes.
|
||||
|
||||
Le job démarre sur le runner la base (même image TimescaleDB que `docker-compose.yml`, base
|
||||
jetable), applique les migrations, y sème un site et deux relevés (`db/seeds/` est vide, pas
|
||||
encore d'outillage de jeu de données pour la CI ; sans données, `GET /sites` rend `[]`, chaque
|
||||
`/{site_id}` rend 404, et le scan actif ne frappe que des gestionnaires d'erreur), démarre le
|
||||
backend, puis `scripts/dast-token.sh` crée un compte **`lecteur`** et rend son jeton.
|
||||
|
||||
ZAP charge le contrat `/openapi.json` depuis un fichier (`zap-api-scan.py -f openapi -t
|
||||
/zap/wrk/openapi.json`) et en importe les 26 opérations **quel que soit le jeton** : c'est le
|
||||
contrat qui décide de ce qui est exploré, pas l'authentification. Le jeton ne change que les
|
||||
réponses obtenues sur les routes gardées : sans lui, elles répondraient toutes `401` plutôt que
|
||||
de dérouler leur logique. Huit routes n'exigent aucun jeton porteur (les deux sondes, `login`,
|
||||
`refresh`, `logout`, `forgot-password`, `reset-password` et `reset-password/validate`) et
|
||||
répondent donc pareil avec ou sans lui.
|
||||
|
||||
Décisions à savoir défendre :
|
||||
|
||||
- **Le compte du scan est `lecteur`, jamais `admin`.** Un scan actif avec un jeton admin frapperait
|
||||
`POST /users` et la réinitialisation de mots de passe pour de bon. Le script passe par un admin
|
||||
jetable pour créer le lecteur (l'API n'a pas d'inscription publique) puis ne s'en sert plus.
|
||||
- **Un compte neuf est en `must_change_password`**, et toute route gardée le refuse tant que le
|
||||
mot de passe n'est pas changé. Le script fait ce changement et vérifie `GET /sites` = 200 avant
|
||||
de rendre le jeton ; sans cela, tout le scan authentifié ne testerait que des `403`.
|
||||
`POST /auth/password` rend déjà un nouveau jeton valide (l'`iat` tronqué documenté dans
|
||||
`app/api/deps.py` ne le rejette pas comme antérieur à la session) : le script s'en sert
|
||||
directement plutôt que de se reconnecter, deux hachages Argon2id (19456 Kio chacun) et deux
|
||||
allers-retours de refresh-token de moins sur le chemin critique de la CI.
|
||||
- **`APP_ACCESS_TOKEN_TTL_SECONDS=3600`** (plafond de la configuration) : le jeton par défaut
|
||||
dure 15 minutes. `scanner.maxScanDurationInMins=15` (ci-dessous) borne le scan actif très en
|
||||
dessous, marge comprise pour les étapes qui l'entourent.
|
||||
- **Le jeton ne transite ni par `${{ }}` dans le script de l'étape, ni par l'argv de `docker
|
||||
run`.** Le premier finirait en clair dans le fichier de commande que GitHub écrit sur le disque
|
||||
du runner pour toute la durée de l'étape ; le second serait visible par `ps aux` et par
|
||||
`docker inspect zap` tant que le conteneur existe. Il est écrit dans un fichier de
|
||||
configuration ZAP séparé (`-configfile`), monté en lecture seule hors de `/zap/wrk` pour ne
|
||||
jamais atterrir dans l'artefact publié. ZAP journalise malgré tout la valeur de chaque
|
||||
`-config`/`-configfile` chargé à un niveau visible sans `-d` : les copies de `zap.log` et
|
||||
`zap-stdout.log` publiées en artefact sont donc caviardées avant publication.
|
||||
|
||||
**Deux pièges d'autorisation** sur ce fichier de configuration (`zap-auth.conf`), tous les deux
|
||||
propres au montage bind Docker : le conteneur y lit avec son propre uid (1000), distinct de celui
|
||||
du runner qui l'a écrit, sans remappage automatique.
|
||||
|
||||
- Un `chmod 600` seul rend le fichier illisible pour le conteneur (« File not readable :
|
||||
/zap/auth.conf »). ZAP échoue dès le lancement, mais `zap-api-scan.py` attend les `-T` minutes
|
||||
complètes avant d'abandonner : dix minutes qui ressemblent à un scan actif, pour un daemon mort
|
||||
depuis le début. Corrigé par `sudo chown 1000:1000` du fichier avant de le passer à `644`.
|
||||
- Ce `chown` déplace la propriété du fichier hors de l'utilisateur du runner : un `chmod` qui
|
||||
suit sans `sudo` échoue alors (« Operation not permitted »), et le `-e` implicite des étapes
|
||||
bash de GitHub Actions arrête toute l'étape avant même `docker run` — un scan « réussi » en une
|
||||
fraction de seconde, sans le moindre journal ni rapport produit. Les deux commandes doivent
|
||||
passer par `sudo`.
|
||||
|
||||
Les routes d'authentification qui changent l'état du compte (`login`, `password`, `logout-all`,
|
||||
`forgot-password`, `reset-password`) sont exclues du scan actif : elles y déclencheraient la
|
||||
limitation de débit et fermeraient les sessions sans rien apprendre de plus.
|
||||
|
||||
**Un scan vert n'est pas un scan qui a testé quelque chose.** Deux garde-fous, eux, **bloquent** :
|
||||
|
||||
- **Moins de 80% des opérations du contrat importées.** Constaté une première fois : 2 URL sur 26
|
||||
opérations importées, ZAP n'avait envoyé que des requêtes vouées au 404 (l'analyseur de ZAP
|
||||
refusait alors le nom accentué d'un des deux schémas de sécurité du contrat, corrigé depuis en
|
||||
ASCII côté backend). Le seuil est dérivé du contrat (`zap-out/openapi.json`, présent à cette
|
||||
étape) plutôt que d'un nombre fixe : un contrat qui grossit ne doit pas rendre la garde plus
|
||||
permissive qu'elle ne l'était.
|
||||
- **Aucune réponse 2xx.** Constaté une deuxième fois, cause différente : la clé de configuration
|
||||
du nom d'en-tête pour la règle Replacer est `matchstr`, pas `matchstring` (celui-ci n'existe que
|
||||
pour le job d'automatisation ZAP, pas pour `-config`) ; ZAP acceptait la mauvaise clé sans
|
||||
erreur et laissait le nom d'en-tête vide, qu'uvicorn refusait par un `400` sur **toute** requête,
|
||||
y compris les routes publiques. Piège de conception rencontré en corrigeant cette garde : borner
|
||||
le *pourcentage* de 4xx ne marche pas, un scan actif fuzze délibérément un grand nombre
|
||||
d'entrées invalides, si bien qu'un scan sain contre l'API seedée reste à 98% de 4xx avec
|
||||
seulement 1% de 2xx. C'est la forme normale d'un scan actif. Le signal qui distingue vraiment un
|
||||
scan cassé (2xx nul, absent du rapport dans les deux incidents) d'un scan sain (2xx non nul,
|
||||
aussi faible soit-il) est l'absence de succès, pas la part d'échecs. Les deux gardes lisent
|
||||
`zap-out/zap-report.json` (champs structurés `insights[]`), pas le texte libre du rapport
|
||||
Markdown.
|
||||
|
||||
Le journal interne de ZAP (`zap.log`) et sa sortie complète (`zap-stdout.log`) sont publiés dans
|
||||
l'artefact `zap-report` (dossier `zap-logs/`, propriété du runner : `zap-out/` bascule sous l'uid
|
||||
1000 du conteneur ZAP dès que le contrat y est copié, le runner n'y écrit plus ensuite) pour
|
||||
diagnostiquer un futur import raté.
|
||||
|
||||
**Non bloquant pour l'instant** (`continue-on-error`, sur la seule étape du scan) pour ce qui est
|
||||
des alertes elles-mêmes. Le volume d'un premier passage trié est inconnu ; le rapport
|
||||
HTML/JSON/Markdown est publié en artefact `zap-report`, et sa synthèse (jusqu'aux tableaux
|
||||
d'alertes, sans le détail par alerte) dans le résumé du job. Fixer un seuil viendra une fois les
|
||||
alertes triées.
|
||||
|
||||
**Limite à ne pas oublier :** le scan tape la configuration par défaut du backend (`APP_ENV=local`,
|
||||
pas de TLS, pas de reverse proxy). Il remontera des alertes qui n'existent pas derrière le proxy
|
||||
(HSTS absent...) et ne dit **rien** des en-têtes ni du TLS que le proxy pose en production. Un
|
||||
second passage sur la stack complète reste à faire.
|
||||
|
||||
## Ce qui manque, et pourquoi
|
||||
|
||||
| Manque | Issue | Conséquence assumée |
|
||||
|---|---|---|
|
||||
| Images publiées et promues par digest (GHCR) | aucune | Chaque environnement reconstruit ses images : la production n'exécute pas l'artefact validé en recette, mais un second build du même commit |
|
||||
| DAST (OWASP ZAP) | #41 | Aucune vérification sur l'application en fonctionnement, seulement sur le code et les dépendances |
|
||||
| DAST bloquant | #41 | Le scan ZAP existe mais ne bloque rien : aucun seuil n'est fixé tant que les alertes du premier passage ne sont pas triées |
|
||||
| Tests end to end | #46 | Les parcours utilisateur ne sont pas vérifiés en CI |
|
||||
| Tests de charge | #47 | Aucun garde-fou de performance |
|
||||
| Scan d'image de conteneur | aucune | Les `Dockerfile` sont construits en local, pas analysés |
|
||||
|
||||
@@ -38,6 +38,7 @@ lecture seule ; plusieurs lignes resteront à compléter une fois les endpoints
|
||||
| Caviardage des jetons, empreintes, mots de passe et cookies dans les journaux | `app/core/logging.py` | A09, A02 |
|
||||
| Cinq gardes de configuration qui refusent le démarrage plutôt que de dégrader silencieusement | `app/core/config.py` | A05 |
|
||||
| Documentation interactive fermée hors développement, `/metrics` derrière un jeton, sonde qui ne publie plus de version | `app/main.py`, `app/api/security.py` | A05 |
|
||||
| Scan dynamique OWASP ZAP de l'API authentifiée (compte `lecteur` jetable), non bloquant, configuration par défaut du backend uniquement (ni TLS ni en-têtes du reverse proxy) | `.github/workflows/dast.yml`, `scripts/dast-token.sh` | A05, API8 Security Misconfiguration |
|
||||
| En-têtes `nosniff`, `DENY`, `no-referrer`, et `no-store` sur les routes d'authentification | `app/api/middleware.py` | A05 |
|
||||
| Refus de rétrograder ou désactiver le dernier administrateur actif | `app/services/user.py` | A04 Insecure Design |
|
||||
| Amorçage du premier administrateur hors dépôt, mot de passe jamais dans `argv` ni dans Git | `app/cli.py` | A02, A05 |
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Image Airflow EnerVision : ajoute ml/ et apps/backend/ dans leurs propres environnements Python
|
||||
# 3.14, distincts du Python 3.12 qui fait tourner Airflow lui-meme (apache-airflow 2.10 ne supporte
|
||||
# pas 3.14), pour que les DAGs puissent lancer `uv run python -m enervision_ml.train`/`.score`,
|
||||
# 3.14, distincts du Python 3.12 de l'image de base qui fait tourner Airflow lui-meme, pour que
|
||||
# les DAGs puissent lancer `uv run python -m enervision_ml.train`/`.score`,
|
||||
# `app.detection.internal_alerts` et `app.cli` en sous-processus. Airflow ne devient jamais un
|
||||
# consommateur direct de LightGBM, de MLflow ou du SQLAlchemy du backend. Cf. ADR 0008.
|
||||
FROM apache/airflow:2.10.4-python3.12
|
||||
FROM apache/airflow:3.3.2-python3.12
|
||||
|
||||
# LightGBM est compile contre libgomp (OpenMP), absent de l'image de base (minimale, sans
|
||||
# toolchain de compilation). Sans lui : `OSError: libgomp.so.1: cannot open shared object file`
|
||||
@@ -21,9 +21,8 @@ RUN apt-get update \
|
||||
RUN mkdir -p /opt/ml/state /opt/backend && chown -R airflow:root /opt/ml /opt/backend
|
||||
USER airflow
|
||||
|
||||
# L'image de base embarque deja un `uv`, mais trop ancien (0.4.29) pour le format de verrou de
|
||||
# `ml/uv.lock`. On le remplace par la version deja pinnee ailleurs dans le depot
|
||||
# (apps/backend/Dockerfile).
|
||||
# L'image de base embarque deja un `uv`, mais pas celui que le depot epingle par ailleurs
|
||||
# (apps/backend/Dockerfile) : on aligne, pour que le format de verrou lu soit le meme partout.
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.26 /uv /home/airflow/.local/bin/uv
|
||||
|
||||
# Piege : pas de `UV_PROJECT_ENVIRONMENT` global. Il vaudrait pour les deux projets, et `uv run`
|
||||
|
||||
@@ -15,8 +15,8 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from airflow.models.dag import DAG
|
||||
from airflow.operators.bash import BashOperator
|
||||
from airflow.providers.standard.operators.bash import BashOperator
|
||||
from airflow.sdk import DAG
|
||||
|
||||
# Le backend a son propre environnement uv dans l'image (ADR 0008). `--no-sync` et
|
||||
# `env -u VIRTUAL_ENV` : cf. `ml_train.py`, même raisonnement.
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
"""DAG d'import du dataset historique EnerVision (issue #119).
|
||||
|
||||
Orchestre le pipeline existant `app.etl.historical_import` sans dupliquer sa logique ETL.
|
||||
Le dataset historique sert à initialiser l'environnement : le DAG reste donc manuel.
|
||||
|
||||
Le backend est exécuté dans l'environnement `/opt/backend` embarqué dans l'image Airflow,
|
||||
sur le même patron que le DAG `alertes` (ADR 0008).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from airflow.models.dag import DAG
|
||||
from airflow.operators.bash import BashOperator
|
||||
|
||||
COMMANDE_BACKEND = "cd /opt/backend && env -u VIRTUAL_ENV uv run --no-sync python -m"
|
||||
|
||||
CSV_PATH = "/opt/data/raw/all_sites_combined.csv"
|
||||
METADATA_PATH = "/opt/data/raw/dataset_metadata.json"
|
||||
SOURCE_TIMEZONE = "UTC"
|
||||
BATCH_SIZE = 1000
|
||||
|
||||
with DAG(
|
||||
dag_id="historical_import",
|
||||
description="Importe le dataset historique CSV/JSON dans dataset, site et reading.",
|
||||
schedule=None,
|
||||
start_date=datetime(2026, 1, 1),
|
||||
catchup=False,
|
||||
max_active_runs=1,
|
||||
tags=["etl", "historical"],
|
||||
) as dag:
|
||||
BashOperator(
|
||||
task_id="import_historical",
|
||||
bash_command=(
|
||||
f"{COMMANDE_BACKEND} app.etl.historical_import "
|
||||
f"--csv {CSV_PATH} "
|
||||
f"--metadata {METADATA_PATH} "
|
||||
"--source-timezone UTC "
|
||||
"--batch-size 1000"
|
||||
),
|
||||
retries=1,
|
||||
retry_delay=timedelta(minutes=2),
|
||||
execution_timeout=timedelta(minutes=30),
|
||||
)
|
||||
"""DAG d'import du dataset historique EnerVision (issue #119).
|
||||
|
||||
Orchestre le pipeline existant `app.etl.historical_import` sans dupliquer sa logique ETL.
|
||||
Le dataset historique sert à initialiser l'environnement : le DAG reste donc manuel.
|
||||
|
||||
Le backend est exécuté dans l'environnement `/opt/backend` embarqué dans l'image Airflow,
|
||||
sur le même patron que le DAG `alertes` (ADR 0008).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from airflow.providers.standard.operators.bash import BashOperator
|
||||
from airflow.sdk import DAG
|
||||
|
||||
COMMANDE_BACKEND = "cd /opt/backend && env -u VIRTUAL_ENV uv run --no-sync python -m"
|
||||
|
||||
CSV_PATH = "/opt/data/raw/all_sites_combined.csv"
|
||||
METADATA_PATH = "/opt/data/raw/dataset_metadata.json"
|
||||
SOURCE_TIMEZONE = "UTC"
|
||||
BATCH_SIZE = 1000
|
||||
|
||||
with DAG(
|
||||
dag_id="historical_import",
|
||||
description="Importe le dataset historique CSV/JSON dans dataset, site et reading.",
|
||||
schedule=None,
|
||||
start_date=datetime(2026, 1, 1),
|
||||
catchup=False,
|
||||
max_active_runs=1,
|
||||
tags=["etl", "historical"],
|
||||
) as dag:
|
||||
BashOperator(
|
||||
task_id="import_historical",
|
||||
bash_command=(
|
||||
f"{COMMANDE_BACKEND} app.etl.historical_import "
|
||||
f"--csv {CSV_PATH} "
|
||||
f"--metadata {METADATA_PATH} "
|
||||
"--source-timezone UTC "
|
||||
"--batch-size 1000"
|
||||
),
|
||||
retries=1,
|
||||
retry_delay=timedelta(minutes=2),
|
||||
execution_timeout=timedelta(minutes=30),
|
||||
)
|
||||
|
||||
@@ -10,8 +10,8 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from airflow.models.dag import DAG
|
||||
from airflow.operators.bash import BashOperator
|
||||
from airflow.providers.standard.operators.bash import BashOperator
|
||||
from airflow.sdk import DAG
|
||||
|
||||
MODEL_PATH = "/opt/ml/state/models/lightgbm-consumption.txt"
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from airflow.models.dag import DAG
|
||||
from airflow.operators.bash import BashOperator
|
||||
from airflow.providers.standard.operators.bash import BashOperator
|
||||
from airflow.sdk import DAG
|
||||
|
||||
MODEL_PATH = "/opt/ml/state/models/lightgbm-consumption.txt"
|
||||
MLFLOW_TRACKING_URI = "sqlite:////opt/ml/state/mlflow.db"
|
||||
|
||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
||||
description = "DAGs d'orchestration EnerVision (Airflow)"
|
||||
requires-python = ">=3.12,<3.13"
|
||||
dependencies = [
|
||||
"apache-airflow==2.10.4",
|
||||
"apache-airflow==3.3.2",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -5,8 +5,8 @@ from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from airflow.models.baseoperator import BaseOperator
|
||||
from airflow.models.dagbag import DagBag
|
||||
from airflow.dag_processing.dagbag import DagBag
|
||||
from airflow.sdk import BaseOperator
|
||||
|
||||
DAGS_FOLDER = Path(__file__).resolve().parent.parent / "dags"
|
||||
|
||||
@@ -22,7 +22,7 @@ TACHES = [
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def dagbag() -> DagBag:
|
||||
return DagBag(dag_folder=str(DAGS_FOLDER), include_examples=False)
|
||||
return DagBag(dag_folder=str(DAGS_FOLDER))
|
||||
|
||||
|
||||
def test_dags_folder_has_no_import_error(dagbag: DagBag) -> None:
|
||||
@@ -34,22 +34,22 @@ def test_every_expected_dag_is_discovered(dagbag: DagBag) -> None:
|
||||
|
||||
|
||||
def test_ml_train_has_no_schedule(dagbag: DagBag) -> None:
|
||||
assert dagbag.dags["ml_train"].timetable.summary == "None"
|
||||
assert dagbag.dags["ml_train"].schedule is None
|
||||
|
||||
|
||||
def test_ml_score_runs_every_hour(dagbag: DagBag) -> None:
|
||||
# `@hourly` est un alias Airflow pour ce cron, c'est sous cette forme que `.summary` le rend.
|
||||
assert dagbag.dags["ml_score"].timetable.summary == "0 * * * *"
|
||||
# `@hourly` est un alias Airflow pour ce cron, c'est sous cette forme que la timetable le rend.
|
||||
assert dagbag.dags["ml_score"].timetable.expression == "0 * * * *"
|
||||
|
||||
|
||||
def test_alertes_runs_after_the_hourly_scoring(dagbag: DagBag) -> None:
|
||||
# Le decalage n'est pas cosmetique : la regle `anomaly` compare une lecture a la `prediction`
|
||||
# du meme instant, que `ml_score` ecrit a l'heure pile.
|
||||
assert dagbag.dags["alertes"].timetable.summary == "15 * * * *"
|
||||
assert dagbag.dags["alertes"].timetable.expression == "15 * * * *"
|
||||
|
||||
|
||||
def test_historical_import_has_no_schedule(dagbag: DagBag) -> None:
|
||||
assert dagbag.dags["historical_import"].timetable.summary == "None"
|
||||
assert dagbag.dags["historical_import"].schedule is None
|
||||
|
||||
|
||||
def test_ml_train_task_calls_the_training_module(dagbag: DagBag) -> None:
|
||||
|
||||
Generated
+554
-653
File diff suppressed because it is too large
Load Diff
+36
-6
@@ -1,18 +1,48 @@
|
||||
# Infrastructure
|
||||
|
||||
Provisionnement Terraform de la machine on-premise (serveur physique, accessible en SSH).
|
||||
Provisionnement Terraform des machines on-premise. Terraform prepare la machine, GitHub Actions
|
||||
deploie l'application : voir l'[ADR 0010](../docs/adr/0010-terraform-provisionne-github-actions-deploie.md).
|
||||
Rien ici ne construit d'image ni ne lance de conteneur.
|
||||
|
||||
- `terraform/modules` : modules reutilisables.
|
||||
- `k3s` : installe un cluster k3s single-node sur une machine distante via SSH
|
||||
(script officiel `get.k3s.io`) et rapatrie le kubeconfig en local.
|
||||
- `terraform/environments/<env>` : racines Terraform, une par environnement.
|
||||
- `dev` : instancie le module `k3s` sur le serveur de l'ecole.
|
||||
- `prod` : non initialise, voir le ticket dedie.
|
||||
- `terraform/environments/<racine>` : une racine par machine provisionnee.
|
||||
- `vm-eni` : la VM `eadl-2025-nantes-g3`, qui porte les environnements `rec` et `prod`
|
||||
([ADR 0009](../docs/adr/0009-deux-environnements-compose-sur-la-vm-eni.md)). Installe Docker,
|
||||
execute `scripts/provision-host.sh`, enregistre le runner GitHub Actions.
|
||||
- `k3s-cible` : le cluster k3s, cible a terme de `docs/architecture/10-infra.md`. Jamais
|
||||
applique.
|
||||
|
||||
## Usage (environments/dev)
|
||||
## Usage (environments/vm-eni)
|
||||
|
||||
```bash
|
||||
cd infra/terraform/environments/dev
|
||||
cd infra/terraform/environments/vm-eni
|
||||
cp terraform.tfvars.example terraform.tfvars
|
||||
terraform init
|
||||
terraform apply
|
||||
```
|
||||
|
||||
`terraform.tfvars` est ignore par git. Trois valeurs sont a renseigner avant l'apply :
|
||||
|
||||
- `proprietaire` : l'utilisateur qui possede `/srv/enervision` et fait tourner le runner. Il doit
|
||||
deja exister sur la machine.
|
||||
- `runner_version` : a epingler depuis <https://github.com/actions/runner/releases>.
|
||||
- `runner_token` : jeton d'enregistrement, valable une heure et pour une seule inscription.
|
||||
Parametres du depot, Actions, Runners, New self-hosted runner. Seul un administrateur du depot
|
||||
peut le creer.
|
||||
|
||||
Apres l'apply, la machine porte `/srv/enervision/rec` et `/srv/enervision/prod`, chacun avec son
|
||||
`.env` et son certificat. Le premier demarrage reste manuel, `make stack-up` dans chaque dossier ;
|
||||
les suivants sont joues par le runner a chaque push sur `dev` et sur `main`.
|
||||
|
||||
Retirer le runner se fait a la main, depuis les parametres du depot : `terraform destroy` ne le
|
||||
desinscrit pas.
|
||||
|
||||
## Usage (environments/k3s-cible)
|
||||
|
||||
```bash
|
||||
cd infra/terraform/environments/k3s-cible
|
||||
cp terraform.tfvars.example terraform.tfvars # renseigner ssh_host / ssh_private_key_path
|
||||
terraform init
|
||||
terraform apply
|
||||
|
||||
@@ -9,7 +9,7 @@ Terminaison TLS et routage de la stack déployée. Seul composant publié sur le
|
||||
- `tls/` : les deux fichiers que nginx lit, `fullchain.pem` et `privkey.pem`. Ignorés par git.
|
||||
- `acme-deploy-hook.sh` : recopie le résultat de certbot dans `tls/`.
|
||||
|
||||
Pas de `Dockerfile` : l'image officielle `nginx:1.28-alpine` est utilisée telle quelle et la
|
||||
Pas de `Dockerfile` : l'image officielle `nginx:1.31-alpine` est utilisée telle quelle et la
|
||||
configuration est montée en volume par `docker-compose.prod.yml`.
|
||||
|
||||
L'overlay emploie les marqueurs `!override` et `!reset`, qui demandent **Docker Compose 2.24.4
|
||||
@@ -88,7 +88,7 @@ docker run --rm \
|
||||
-v "$PWD/infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro" \
|
||||
-v "$PWD/infra/proxy/conf.d:/etc/nginx/conf.d:ro" \
|
||||
-v "$PWD/infra/proxy/tls:/etc/nginx/tls:ro" \
|
||||
nginx:1.28-alpine nginx -t
|
||||
nginx:1.31-alpine nginx -t
|
||||
```
|
||||
|
||||
Monter `infra/proxy/` entier sur `/etc/nginx` échouerait : `mime.types` vient de l'image.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# This file is maintained automatically by "terraform init".
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.terraform.io/hashicorp/null" {
|
||||
version = "3.3.2"
|
||||
constraints = "~> 3.2"
|
||||
hashes = [
|
||||
"h1:IQ1qrkht1sC1nibUR+AJ3ulryyhVDHfCHZhoJi0sg2Y=",
|
||||
"zh:10ec43b8b7b18d5639238c7fb9e111f6a4b038523dd66c7a426bf27b25fa4c08",
|
||||
"zh:60beb9cc2ad5b871c710860cee75b42850cc6acd43db0d77cb5e00fda7288b55",
|
||||
"zh:62538582d0a4a2f10ad8a8d9a6c3cd3f05af6c6d91c6641ffc78d4f0e8e69b27",
|
||||
"zh:64a8f9ce7852d9efc5b464c12306c946366d59f5e2757def97969c9fd64bd1d6",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:92a374fb736a52f465283326d0a5bf4f495132eb99be209dfb4c75ec803fe8db",
|
||||
"zh:98da9c42785d27a50f0604758bcb61a30f6278b9f2acd92bb3b2046e0e71916c",
|
||||
"zh:b0f7896fae554729cdf4a24ac06359a050cff5817e6cd8597cba8a4ae01a7409",
|
||||
"zh:bc8179ee35d67c72fb03012e7023b9f9816f033a7ec4109c001dd6d29752e812",
|
||||
"zh:d23a598f713bfb6098bc003571d7de90b5a33b78f9be240488252fe5f3c2a60d",
|
||||
"zh:d2855b922ea345dbd89ea287e4c6c4757e38bc0aaffeb2b79aa0b8004f9c53ff",
|
||||
"zh:d3a60422bc6a2f9244d076c5222c07060c826ef91bdbaf4634cb752b86057473",
|
||||
"zh:faa01928c25d2a6ecd9c7eb8b88134cb08de55a6b11ca6c703ac0092845344ba",
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# This file is maintained automatically by "terraform init".
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.terraform.io/hashicorp/null" {
|
||||
version = "3.3.2"
|
||||
constraints = "~> 3.2"
|
||||
hashes = [
|
||||
"h1:IQ1qrkht1sC1nibUR+AJ3ulryyhVDHfCHZhoJi0sg2Y=",
|
||||
"zh:10ec43b8b7b18d5639238c7fb9e111f6a4b038523dd66c7a426bf27b25fa4c08",
|
||||
"zh:60beb9cc2ad5b871c710860cee75b42850cc6acd43db0d77cb5e00fda7288b55",
|
||||
"zh:62538582d0a4a2f10ad8a8d9a6c3cd3f05af6c6d91c6641ffc78d4f0e8e69b27",
|
||||
"zh:64a8f9ce7852d9efc5b464c12306c946366d59f5e2757def97969c9fd64bd1d6",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:92a374fb736a52f465283326d0a5bf4f495132eb99be209dfb4c75ec803fe8db",
|
||||
"zh:98da9c42785d27a50f0604758bcb61a30f6278b9f2acd92bb3b2046e0e71916c",
|
||||
"zh:b0f7896fae554729cdf4a24ac06359a050cff5817e6cd8597cba8a4ae01a7409",
|
||||
"zh:bc8179ee35d67c72fb03012e7023b9f9816f033a7ec4109c001dd6d29752e812",
|
||||
"zh:d23a598f713bfb6098bc003571d7de90b5a33b78f9be240488252fe5f3c2a60d",
|
||||
"zh:d2855b922ea345dbd89ea287e4c6c4757e38bc0aaffeb2b79aa0b8004f9c53ff",
|
||||
"zh:d3a60422bc6a2f9244d076c5222c07060c826ef91bdbaf4634cb752b86057473",
|
||||
"zh:faa01928c25d2a6ecd9c7eb8b88134cb08de55a6b11ca6c703ac0092845344ba",
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
# Pourquoi : Terraform provisionne la machine, GitHub Actions la deploie (ADR 0010). Rien ici ne
|
||||
# construit d'image ni ne lance de conteneur : la livraison reste portee par `deploy.yml` et
|
||||
# `make stack-up`, et un `apply` n'interrompt pas la stack qui tourne.
|
||||
# Piege : seul le bloc `triggers` d'un `null_resource` atterrit dans le state. Ni le jeton du
|
||||
# runner ni la cle SSH n'y figurent, et ne doivent jamais y etre ajoutes pour forcer un rejeu.
|
||||
# Contrainte : pas de provisioner `destroy` sur le runner. Il imposerait une connexion ne lisant
|
||||
# que `self`, donc le chemin de la cle SSH dans le state, et `svc.sh uninstall` ne desinscrit pas
|
||||
# le runner cote GitHub : le retrait reste manuel, depuis les parametres du depot.
|
||||
# Ref : ADR 0009 pour les deux environnements, `scripts/provision-host.sh` pour leur contenu.
|
||||
|
||||
locals {
|
||||
sudo = var.ssh_user == "root" ? "" : "sudo "
|
||||
en_tant_que = "${var.ssh_user == "root" ? "" : "sudo "}runuser -u ${var.proprietaire} --"
|
||||
provisionneur = "${path.root}/../../../../scripts/provision-host.sh"
|
||||
runner_archive = "actions-runner-linux-x64-${var.runner_version}.tar.gz"
|
||||
# Substitution shell, evaluee par le sh -c distant : un nom de runner doit etre unique dans
|
||||
# le depot, le nom d'hote l'est deja et le reste si cette racine sert a une autre machine.
|
||||
runner_nom = var.runner_nom != "" ? var.runner_nom : "$(hostname -s)"
|
||||
}
|
||||
|
||||
resource "null_resource" "docker_engine" {
|
||||
triggers = {
|
||||
hote = var.ssh_host
|
||||
user = var.proprietaire
|
||||
}
|
||||
|
||||
connection {
|
||||
type = "ssh"
|
||||
host = var.ssh_host
|
||||
port = var.ssh_port
|
||||
user = var.ssh_user
|
||||
private_key = file(pathexpand(var.ssh_private_key_path))
|
||||
timeout = "5m"
|
||||
}
|
||||
|
||||
provisioner "remote-exec" {
|
||||
inline = [
|
||||
<<-EOT
|
||||
set -eu
|
||||
id ${var.proprietaire} >/dev/null 2>&1 || {
|
||||
echo "l'utilisateur ${var.proprietaire} n'existe pas sur la machine" >&2
|
||||
exit 1
|
||||
}
|
||||
command -v docker >/dev/null || ${local.sudo}sh -c 'curl -fsSL https://get.docker.com | sh'
|
||||
${local.sudo}systemctl enable --now docker
|
||||
${local.sudo}usermod -aG docker ${var.proprietaire}
|
||||
${local.sudo}docker compose version
|
||||
EOT
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
# `provision-host.sh` verifie lui-meme docker, compose et la sortie HTTPS, puis prepare un clone
|
||||
# par environnement, son `.env` et son certificat. Il est rejouable : un `.env` existant n'est
|
||||
# jamais reecrit, un certificat present jamais regenere.
|
||||
resource "null_resource" "environnements" {
|
||||
depends_on = [null_resource.docker_engine]
|
||||
|
||||
triggers = {
|
||||
script = filesha256(local.provisionneur)
|
||||
racine = var.racine
|
||||
depot = var.depot_url
|
||||
}
|
||||
|
||||
connection {
|
||||
type = "ssh"
|
||||
host = var.ssh_host
|
||||
port = var.ssh_port
|
||||
user = var.ssh_user
|
||||
private_key = file(pathexpand(var.ssh_private_key_path))
|
||||
timeout = "5m"
|
||||
}
|
||||
|
||||
provisioner "file" {
|
||||
source = local.provisionneur
|
||||
destination = "/tmp/provision-host.sh"
|
||||
}
|
||||
|
||||
provisioner "remote-exec" {
|
||||
inline = [
|
||||
<<-EOT
|
||||
set -eu
|
||||
${local.sudo}env RACINE='${var.racine}' \
|
||||
REPO_URL='${var.depot_url}' \
|
||||
PROPRIETAIRE='${var.proprietaire}' \
|
||||
PUBLIC_IP='${var.adresse_publique}' \
|
||||
bash /tmp/provision-host.sh
|
||||
rm -f /tmp/provision-host.sh
|
||||
EOT
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
# Piege : le jeton d'enregistrement expire en une heure. Un `apply` rejoue cette ressource des
|
||||
# que `runner_version`, `runner_labels` ou `runner_nom` change, et redemande donc un jeton frais.
|
||||
resource "null_resource" "runner_github" {
|
||||
depends_on = [null_resource.environnements]
|
||||
|
||||
triggers = {
|
||||
version = var.runner_version
|
||||
labels = var.runner_labels
|
||||
nom = local.runner_nom
|
||||
dossier = var.runner_dossier
|
||||
}
|
||||
|
||||
connection {
|
||||
type = "ssh"
|
||||
host = var.ssh_host
|
||||
port = var.ssh_port
|
||||
user = var.ssh_user
|
||||
private_key = file(pathexpand(var.ssh_private_key_path))
|
||||
timeout = "5m"
|
||||
}
|
||||
|
||||
provisioner "remote-exec" {
|
||||
inline = [
|
||||
<<-EOT
|
||||
set -eu
|
||||
${local.sudo}install -d -o ${var.proprietaire} -g ${var.proprietaire} ${var.runner_dossier}
|
||||
if [ ! -x ${var.runner_dossier}/config.sh ]; then
|
||||
curl -fsSL -o /tmp/${local.runner_archive} \
|
||||
https://github.com/actions/runner/releases/download/v${var.runner_version}/${local.runner_archive}
|
||||
${local.sudo}tar -xzf /tmp/${local.runner_archive} -C ${var.runner_dossier}
|
||||
${local.sudo}chown -R ${var.proprietaire}:${var.proprietaire} ${var.runner_dossier}
|
||||
rm -f /tmp/${local.runner_archive}
|
||||
fi
|
||||
if [ ! -f ${var.runner_dossier}/.runner ]; then
|
||||
${local.en_tant_que} sh -c 'cd ${var.runner_dossier} && ./config.sh --unattended --replace \
|
||||
--url ${var.runner_url} --token ${var.runner_token} \
|
||||
--labels ${var.runner_labels} --name ${local.runner_nom} --work _work'
|
||||
${local.sudo}${var.runner_dossier}/svc.sh install ${var.proprietaire}
|
||||
fi
|
||||
${local.sudo}${var.runner_dossier}/svc.sh start
|
||||
EOT
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
output "machine" {
|
||||
description = "Machine provisionnee et racine qui porte un clone par environnement."
|
||||
value = "${var.ssh_user}@${var.ssh_host}:${var.racine}"
|
||||
}
|
||||
|
||||
output "runner" {
|
||||
description = "Dossier d'installation du runner et libelles supplementaires annonces a GitHub."
|
||||
value = "${var.runner_dossier} (${var.runner_labels})"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
ssh_host = "10.101.200.37"
|
||||
ssh_port = 22
|
||||
ssh_user = "root"
|
||||
ssh_private_key_path = "~/.ssh/id_ed25519"
|
||||
|
||||
# Utilisateur qui possede la racine et fait tourner le runner. Il doit deja exister sur la
|
||||
# machine : git refuse les depots appartenant a un autre utilisateur, et un .env en 600 lui
|
||||
# echapperait.
|
||||
proprietaire = "enervision"
|
||||
|
||||
# Epingler une version reelle : https://github.com/actions/runner/releases
|
||||
runner_version = "2.330.0"
|
||||
|
||||
# Jeton d'enregistrement du runner, valable une heure et pour une seule inscription :
|
||||
# Parametres du depot > Actions > Runners > New self-hosted runner. Seul un administrateur du
|
||||
# depot peut le creer. terraform.tfvars est ignore par git, mais le jeton ne doit pas y rester
|
||||
# apres l'apply.
|
||||
runner_token = "A_RENSEIGNER"
|
||||
|
||||
# Nom du runner cote GitHub. Vide par defaut : le nom d'hote de la machine. A renseigner
|
||||
# seulement si deux runners doivent tourner sur la meme machine, leurs noms devant differer.
|
||||
# runner_nom = "eni-g3-bis"
|
||||
@@ -0,0 +1,90 @@
|
||||
variable "ssh_host" {
|
||||
type = string
|
||||
description = "Adresse de la VM ENI qui porte les deux environnements (ADR 0009)."
|
||||
}
|
||||
|
||||
variable "ssh_port" {
|
||||
type = number
|
||||
description = "Port SSH de la VM."
|
||||
default = 22
|
||||
}
|
||||
|
||||
variable "ssh_user" {
|
||||
type = string
|
||||
description = "Utilisateur SSH du provisionnement. Different de root, les commandes privilegiees sont prefixees par sudo."
|
||||
default = "root"
|
||||
}
|
||||
|
||||
variable "ssh_private_key_path" {
|
||||
type = string
|
||||
description = "Chemin local vers la cle privee SSH. L'authentification par mot de passe n'est volontairement pas prise en charge : une variable de mot de passe finit en clair dans le state ou dans les triggers."
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "proprietaire" {
|
||||
type = string
|
||||
description = "Utilisateur qui possede la racine et fait tourner le runner. Il doit exister sur la machine : git refuse les depots appartenant a un autre utilisateur, et un .env en 600 lui echapperait."
|
||||
|
||||
validation {
|
||||
condition = can(regex("^[a-z_][a-z0-9_-]*$", var.proprietaire))
|
||||
error_message = "proprietaire doit etre un nom d'utilisateur Unix valide."
|
||||
}
|
||||
}
|
||||
|
||||
variable "racine" {
|
||||
type = string
|
||||
description = "Dossier qui porte un clone du depot par environnement."
|
||||
default = "/srv/enervision"
|
||||
}
|
||||
|
||||
variable "depot_url" {
|
||||
type = string
|
||||
description = "URL de clonage du depot, passee a provision-host.sh."
|
||||
default = "https://github.com/ineszang/ProjetPiscine_EnerVision.git"
|
||||
}
|
||||
|
||||
variable "adresse_publique" {
|
||||
type = string
|
||||
description = "Adresse annoncee dans les certificats auto-signes. Vide : la premiere adresse de la VM."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "runner_url" {
|
||||
type = string
|
||||
description = "Depot GitHub auquel le runner s'enregistre."
|
||||
default = "https://github.com/ineszang/ProjetPiscine_EnerVision"
|
||||
}
|
||||
|
||||
variable "runner_version" {
|
||||
type = string
|
||||
description = "Version d'actions-runner a installer, sans le v initial (ex: 2.330.0). Voir https://github.com/actions/runner/releases."
|
||||
|
||||
validation {
|
||||
condition = can(regex("^[0-9]+\\.[0-9]+\\.[0-9]+$", var.runner_version))
|
||||
error_message = "runner_version doit etre epinglee explicitement (ex: 2.330.0), sinon l'installation cesse d'etre reproductible."
|
||||
}
|
||||
}
|
||||
|
||||
variable "runner_token" {
|
||||
type = string
|
||||
description = "Jeton d'enregistrement du runner. Expire au bout d'une heure et ne vaut que pour une inscription : Parametres du depot > Actions > Runners > New self-hosted runner. Seul un administrateur du depot peut le creer."
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "runner_labels" {
|
||||
type = string
|
||||
description = "Libelles supplementaires du runner. deploy.yml cible [self-hosted, linux, eni-g3], les deux premiers etant poses par GitHub."
|
||||
default = "eni-g3"
|
||||
}
|
||||
|
||||
variable "runner_nom" {
|
||||
type = string
|
||||
description = "Nom du runner cote GitHub, unique dans le depot. Vide : le nom d'hote de la machine, qui reste unique si cette racine est reprise pour une seconde VM. A renseigner pour faire tourner deux runners sur la meme machine."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "runner_dossier" {
|
||||
type = string
|
||||
description = "Dossier d'installation du runner sur la machine."
|
||||
default = "/opt/actions-runner"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
terraform {
|
||||
required_version = ">= 1.7"
|
||||
|
||||
required_providers {
|
||||
null = {
|
||||
source = "hashicorp/null"
|
||||
version = "~> 3.2"
|
||||
}
|
||||
}
|
||||
|
||||
backend "local" {
|
||||
path = "terraform.tfstate"
|
||||
}
|
||||
}
|
||||
@@ -5,19 +5,26 @@ locals {
|
||||
kubeconfig_cmd = "${local.sudo_prefix}cat /etc/rancher/k3s/k3s.yaml"
|
||||
}
|
||||
|
||||
# Piege : un provisioner `destroy` impose que tout le bloc `connection` ne lise que `self`, sinon
|
||||
# `terraform init` refuse le module. D'ou la connexion batie sur `triggers`, ou ne figurent que
|
||||
# l'adresse, le port, l'utilisateur et le chemin de la cle : jamais la cle ni un mot de passe.
|
||||
resource "null_resource" "k3s_install" {
|
||||
triggers = {
|
||||
ssh_host = var.ssh_host
|
||||
k3s_version = var.k3s_version
|
||||
disable_components = join(",", var.k3s_disable_components)
|
||||
ssh_host = var.ssh_host
|
||||
ssh_port = tostring(var.ssh_port)
|
||||
ssh_user = var.ssh_user
|
||||
ssh_key_path = var.ssh_private_key_path
|
||||
sudo_prefix = local.sudo_prefix
|
||||
k3s_version = var.k3s_version
|
||||
disable_components = join(",", var.k3s_disable_components)
|
||||
}
|
||||
|
||||
connection {
|
||||
type = "ssh"
|
||||
host = var.ssh_host
|
||||
port = var.ssh_port
|
||||
user = var.ssh_user
|
||||
private_key = file(var.ssh_private_key_path)
|
||||
host = self.triggers.ssh_host
|
||||
port = tonumber(self.triggers.ssh_port)
|
||||
user = self.triggers.ssh_user
|
||||
private_key = file(pathexpand(self.triggers.ssh_key_path))
|
||||
}
|
||||
|
||||
provisioner "remote-exec" {
|
||||
@@ -33,7 +40,7 @@ resource "null_resource" "k3s_install" {
|
||||
when = destroy
|
||||
on_failure = continue
|
||||
inline = [
|
||||
"${local.sudo_prefix}sh -c 'test -x /usr/local/bin/k3s-uninstall.sh && /usr/local/bin/k3s-uninstall.sh || true'",
|
||||
"${self.triggers.sudo_prefix}sh -c 'test -x /usr/local/bin/k3s-uninstall.sh && /usr/local/bin/k3s-uninstall.sh || true'",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
# Scripts
|
||||
|
||||
Outillage local du monorepo. Les taches courantes passent par le `Makefile` racine.
|
||||
|
||||
## dast-token.sh
|
||||
|
||||
Prépare le scan DAST (`.github/workflows/dast.yml`) : sur une API déjà démarrée, crée un compte
|
||||
`lecteur` jetable, lui fait passer le changement de mot de passe obligatoire et écrit son jeton
|
||||
d'accès sur la sortie standard. À lancer depuis `apps/backend`, contre une base **jetable** (il y
|
||||
crée deux comptes) : `BASE_URL=http://localhost:8000 ../../scripts/dast-token.sh`. Nécessite `curl`,
|
||||
`jq` et `openssl`.
|
||||
|
||||
Executable
+78
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
# Prépare le scan DAST : crée un compte `lecteur` sur une API déjà démarrée, lui fait passer le
|
||||
# changement de mot de passe obligatoire, et écrit son jeton d'accès sur la sortie standard.
|
||||
#
|
||||
# Piège : un compte neuf est en `must_change_password`, et toute route gardée le refuse tant que
|
||||
# le mot de passe n'a pas été changé. Sans cette étape, ZAP ne verrait que 403 sur les routes
|
||||
# gardées et le scan ne testerait rien de l'API authentifiée.
|
||||
#
|
||||
# Contrainte : le compte du scan est `lecteur`, jamais `admin`. Un scan actif avec un jeton admin
|
||||
# frapperait POST /users ou la réinitialisation de mots de passe pour de bon.
|
||||
#
|
||||
# L'administrateur n'existe que pour créer ce compte (l'API n'a pas d'inscription publique).
|
||||
# À lancer depuis apps/backend, dans un environnement où DATABASE_URL et APP_SECRET_KEY visent
|
||||
# une base JETABLE : le script y crée deux comptes.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-http://localhost:8000}"
|
||||
API="$BASE_URL/api/v1"
|
||||
SUFFIXE="$(openssl rand -hex 4)"
|
||||
EMAIL_ADMIN="dast-admin-$SUFFIXE@enervision.fr"
|
||||
EMAIL_LECTEUR="dast-lecteur-$SUFFIXE@enervision.fr"
|
||||
|
||||
# Classes exigées par le validateur : majuscule, minuscule, chiffre, caractère spécial.
|
||||
nouveau_mot_de_passe() { echo "Dast-$(openssl rand -hex 12)-Aa1!"; }
|
||||
|
||||
# Tout ce qui n'est pas la sortie finale part sur stderr : la sortie standard ne porte que le jeton.
|
||||
journal() { echo "dast-token: $*" >&2; }
|
||||
|
||||
connexion() {
|
||||
local email="$1" mot_de_passe="$2"
|
||||
curl -fsS -X POST "$API/auth/login" -H 'Content-Type: application/json' \
|
||||
-d "$(jq -n --arg e "$email" --arg p "$mot_de_passe" '{email:$e, password:$p}')" \
|
||||
| jq -r '.access_token'
|
||||
}
|
||||
|
||||
# Rend le nouveau jeton d'accès : `/auth/password` en émet un (avec l'`iat` de la session en
|
||||
# cours, cf. le piège documenté dans `app/api/deps.py`), pas seulement une confirmation. S'y fier
|
||||
# évite une reconnexion, donc un second hachage Argon2id (19456 Kio) et un aller-retour de
|
||||
# refresh-token superflus sur le chemin critique de la CI.
|
||||
changer_mot_de_passe() {
|
||||
local jeton="$1" ancien="$2" nouveau="$3"
|
||||
curl -fsS -X POST "$API/auth/password" \
|
||||
-H "Authorization: Bearer $jeton" -H 'Content-Type: application/json' \
|
||||
-d "$(jq -n --arg a "$ancien" --arg n "$nouveau" '{current_password:$a, new_password:$n}')" \
|
||||
| jq -r '.access_token'
|
||||
}
|
||||
|
||||
journal "création de l'administrateur $EMAIL_ADMIN"
|
||||
if ! SORTIE="$(uv run --frozen --no-sync --no-build python -m app.cli create-admin --email "$EMAIL_ADMIN" --generate)"; then
|
||||
journal "la création de l'administrateur a échoué :"
|
||||
journal "$SORTIE"
|
||||
exit 1
|
||||
fi
|
||||
MDP_ADMIN="$(sed -n 's/^Mot de passe généré, il ne sera plus affiché : //p' <<<"$SORTIE")"
|
||||
[[ -n "$MDP_ADMIN" ]] || { journal "mot de passe administrateur introuvable dans la sortie :"; journal "$SORTIE"; exit 1; }
|
||||
|
||||
JETON="$(connexion "$EMAIL_ADMIN" "$MDP_ADMIN")"
|
||||
NOUVEAU_ADMIN="$(nouveau_mot_de_passe)"
|
||||
JETON="$(changer_mot_de_passe "$JETON" "$MDP_ADMIN" "$NOUVEAU_ADMIN")"
|
||||
|
||||
journal "création du lecteur $EMAIL_LECTEUR"
|
||||
REPONSE="$(curl -fsS -X POST "$API/users" -H "Authorization: Bearer $JETON" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$(jq -n --arg e "$EMAIL_LECTEUR" '{email:$e, role:"lecteur"}')")"
|
||||
MDP_TEMPORAIRE="$(jq -r '.temporary_password // empty' <<<"$REPONSE")"
|
||||
[[ -n "$MDP_TEMPORAIRE" ]] || { journal "mot de passe temporaire introuvable dans la réponse de POST /users :"; journal "$REPONSE"; exit 1; }
|
||||
|
||||
JETON="$(connexion "$EMAIL_LECTEUR" "$MDP_TEMPORAIRE")"
|
||||
NOUVEAU_LECTEUR="$(nouveau_mot_de_passe)"
|
||||
JETON="$(changer_mot_de_passe "$JETON" "$MDP_TEMPORAIRE" "$NOUVEAU_LECTEUR")"
|
||||
|
||||
# Vérifie que le jeton ouvre bien une route gardée avant de le rendre.
|
||||
CODE="$(curl -sS -o /dev/null -w '%{http_code}' "$API/sites" -H "Authorization: Bearer $JETON")"
|
||||
[[ "$CODE" == "200" ]] || { journal "GET /sites répond $CODE avec le jeton du lecteur, attendu 200"; exit 1; }
|
||||
|
||||
journal "jeton du lecteur prêt"
|
||||
echo "$JETON"
|
||||
@@ -47,13 +47,15 @@ preparer() {
|
||||
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_WEBSERVER_SECRET_KEY=.*|AIRFLOW_WEBSERVER_SECRET_KEY=$(secret)|" \
|
||||
-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|" \
|
||||
@@ -61,13 +63,21 @@ preparer() {
|
||||
-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|" \
|
||||
"$dossier/.env.example" > "$dossier/.env"
|
||||
"$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%%=*}=" "$dossier/.env" || echo "$cle" >> "$dossier/.env"
|
||||
grep -q "^${cle%%=*}=" "$brouillon" || echo "$cle" >> "$brouillon"
|
||||
done
|
||||
chmod 600 "$dossier/.env"
|
||||
# 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."
|
||||
fi
|
||||
|
||||
|
||||
Reference in New Issue
Block a user