Conteneurisation Docker + tests de release (container-test, mode --release)
Conteneurisation (site 100 % statique — l'image ne détient AUCUNE donnée, vie privée identique) : - Dockerfile : nginxinc/nginx-unprivileged:alpine (uid 101, port 8080, pas de root), healthcheck wget intégré, labels OCI - nginx.conf : no-cache systématique (cohérence du jeu de fichiers à chaque mise à jour d'image, coût nul : ~600 Ko + gzip sur l'asset PK), gzip, en-têtes de sécurité, deny des dotfiles - .dockerignore : runtime uniquement (docs/ inclus — dialog changelog) ; local-test-data exclu en filet de sécurité - docker-compose.yml : read_only + tmpfs (/var/cache/nginx, /run, /tmp — les temporaires de nginx-unprivileged, constat au premier test réel) + no-new-privileges Nouveaux tests avant release : - scripts/container-test.sh : build + run DURCI (config compose) + healthcheck healthy + endpoints 200 + headers (no-cache/nosniff/DENY/ no-referrer) + gzip réel + MIME strict des modules ES + fichiers cachés non servis + image propre (pas de node_modules/npm) + docs/ embarqué + E2E playwright complet CONTRE LE CONTENEUR (HRT_E2E_BASE) - scripts/e2e.mjs : mode externe HRT_E2E_BASE (pilote un site déjà déployé — conteneur inclus, serveur local non démarré) - scripts/check.sh --release : supplée les 9 vérifications (version ↔ changelog ↔ tag ↔ arbre propre + test conteneur si daemon) Runtime Docker local installé pour le CI-like (colima 2 CPU / 2 Go) : premier build + run réel = 2 problèmes testés et corrigés (tmpfs /tmp, key case header_json curl). CONTAINER TEST OK en configuration durcie.
This commit is contained in:
parent
491efef8eb
commit
a0245e72c2
27
.dockerignore
Normal file
27
.dockerignore
Normal file
@ -0,0 +1,27 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# .dockerignore — ce qui n'entre PAS dans l'image de déploiement.
|
||||
#
|
||||
# L'image ne sert que le RUNTIME : app (index.html, css/, js/, assets/),
|
||||
# docs/ (⚠️ requis — le dialog « Nouveautés » fetch docs/CHANGELOG.md),
|
||||
# LICENSE/README. Tout le reste est exclu :
|
||||
# - .git, node_modules, package-lock.json : jamais dans une image ;
|
||||
# - tests/, scripts/ : outils de dev — inutiles au runtime (et l'image
|
||||
# devient plus propre à auditer : `docker run … find /usr/share/nginx/html`);
|
||||
# - local-test-data/ : DOUBLON DE SÉCURITÉ — exports de santé réels jamais
|
||||
# versionnés (cf docs/DEVELOPPEMENT.md §3) ; s'ils existaient par erreur
|
||||
# à la racine du contexte de build, ils n'iraient JAMAIS dans l'image ;
|
||||
# - fichiers de config du conteneur exclus aussi (Dockerfile/compose sont
|
||||
# des instructions, pas des fichiers à servir).
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
.git
|
||||
.gitignore
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
node_modules
|
||||
package-lock.json
|
||||
tests
|
||||
scripts
|
||||
local-test-data
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
41
Dockerfile
Normal file
41
Dockerfile
Normal file
@ -0,0 +1,41 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# HormoneTrack Web — image de déploiement Docker (site statique, 100 % local).
|
||||
#
|
||||
# POURQUOI C'EST SIMPLE : l'app n'a AUCUN build (ES modules natifs) et AUCUN
|
||||
# composant serveur — « conteneuriser » = servir les fichiers avec nginx.
|
||||
# Le conteneur ne détient AUCUNE donnée : tout vit dans le localStorage des
|
||||
# navigateurs clients (le serveur ne fait que distribuer des fichiers, cf
|
||||
# docs/DEVELOPPEMENT.md §10 — la vie privée est inchangée).
|
||||
#
|
||||
# Choix d'image : `nginx-unprivileged` (utilisateur 101, port 8080, écritures
|
||||
# limitées à /tmp) — meilleure pratique conteneur (pas de root), même image
|
||||
# que pour un self-host NAS/VPS. Alpine = ~50 Mo décompressé.
|
||||
#
|
||||
# Build : docker build -t hormonetrack-web .
|
||||
# Run : docker run -p 8080:8080 hormonetrack-web (ou docker compose up -d)
|
||||
# Cf docs/DEVELOPPEMENT.md §10 « Déploiement ».
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
FROM nginxinc/nginx-unprivileged:alpine
|
||||
|
||||
# ── Métadonnées OCI (traçabilité du conteneur déployé) ───────────────────────
|
||||
LABEL org.opencontainers.image.title="HormoneTrack Web" \
|
||||
org.opencontainers.image.description="Suivi de thérapie hormonale — app web 100 % locale (site statique). Portage navigateur de l'app Android." \
|
||||
org.opencontainers.image.source="https://gitea.cloudyfy.fr/Siphonight/HormoneTrack-web" \
|
||||
org.opencontainers.image.licenses="GPL-3.0"
|
||||
|
||||
# ── Config nginx spécifique (port non-privilégié, no-cache, gzip, sécurité) ──
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# ── Fichiers du site ────────────────────────────────────────────────────────
|
||||
# Tout le dépôt SAUF ce qui n'est pas runtime (tests, scripts, npm) — cf
|
||||
# .dockerignore. ⚠️ docs/ DOIT rester dans l'image : le dialog « Nouveautés »
|
||||
# fetch docs/CHANGELOG.md au démarrage.
|
||||
COPY . /usr/share/nginx/html/
|
||||
|
||||
# Le site est statique : le healthcheck synthétique vérifie que nginx répond
|
||||
# (busybox wget est présent dans l'image alpine).
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget -q -O /dev/null http://127.0.0.1:8080/ || exit 1
|
||||
|
||||
# Port non privilégié (l'image unprivileged n'a pas le droit de binde <1024)
|
||||
EXPOSE 8080
|
||||
21
README.md
21
README.md
@ -57,6 +57,27 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
### Avec Docker (self-host NAS/VPS)
|
||||
|
||||
L'image ne fait **que servir les fichiers** : aucune donnée dedans, la vie
|
||||
privée est identique (les données vivent dans les navigateurs). Build et
|
||||
exécution :
|
||||
|
||||
```bash
|
||||
docker compose up -d # build + run → http://<hôte>:8080/
|
||||
docker compose up -d --build # rebuild après mise à jour du dépôt
|
||||
```
|
||||
|
||||
- Image **non privilégiée** (`nginx-unprivileged`, utilisateur 101, port
|
||||
8080), filesystem en **lecture seule** (composé prêt pour le NAS),
|
||||
healthcheck intégré, gzip sur l'asset PK (550 Ko → ~150 Ko).
|
||||
- Headers `no-cache` : une mise à jour d'image est prise en compte au
|
||||
rechargement suivant, sans code périmé chez les clients.
|
||||
- Validation en 3 commandes sur l'hôte qui a Docker (cf
|
||||
[docs/DEVELOPPEMENT.md §10](docs/DEVELOPPEMENT.md)) : build, run, puis
|
||||
`HRT_E2E_BASE=http://127.0.0.1:8080 node scripts/e2e.mjs` — le même test
|
||||
E2E que le CI pilote le site SERVI PAR LE CONTENEUR.
|
||||
|
||||
Aucun build, aucune variable d'environnement, aucun composant serveur : les
|
||||
données de chaque personne vivent **dans SON navigateur**, jamais sur la
|
||||
machine qui sert les fichiers.
|
||||
|
||||
34
docker-compose.yml
Normal file
34
docker-compose.yml
Normal file
@ -0,0 +1,34 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# docker-compose — un commandement pour tourner HormoneTrack Web.
|
||||
#
|
||||
# docker compose up -d → http://<hôte>:8080/
|
||||
# docker compose up -d --build (rebuild après mise à jour du dépôt)
|
||||
#
|
||||
# Le conteneur ne détient AUCUNE donnée (site statique) : le volume n'est
|
||||
# pas nécessaire, la persistance vit dans les navigateurs. Pas de variable
|
||||
# d'environnement, pas de volume — la config = le code servi.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
# Nom d'image stable : retaguer par version à chaque release sync si
|
||||
# tu veux garder un historique (docker tag hormonetrack-web hormonetrack-web:vX.Y.Z)
|
||||
image: hormonetrack-web:latest
|
||||
container_name: hormonetrack-web
|
||||
ports:
|
||||
- "8080:8080" # port non privilégié de l'image unprivileged nginx
|
||||
restart: unless-stopped
|
||||
|
||||
# ── Durcissement (site statique : AUCUNE écriture nécessaire) ───────────
|
||||
# Filesystem en lecture seule + tmpfs pour les seuls dirs que nginx
|
||||
# touche : l'image unprivileged place ses temporaires dans /tmp
|
||||
# (proxy_temp, client_temp, pid) — sans ce tmpfs, nginx émerge
|
||||
# « mkdir /tmp/proxy_temp failed (30: Read-only file system) » au
|
||||
# démarrage avec read_only (constaté au premier container-test 8 sept.).
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /var/cache/nginx
|
||||
- /run
|
||||
- /tmp
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
@ -521,12 +521,56 @@ push initial v1.2.3, cf doc Android §16.)
|
||||
3. si l'asset PK change : les DEUX dépôts dans la même session (tests
|
||||
pins identiques) — jamais un seul côté.
|
||||
|
||||
### Déploiement du site (quand décidé)
|
||||
### Déploiement — Docker (recommandé pour self-host)
|
||||
|
||||
- **copier le dépôt** (ou `git archive vX.Y.Z`) sur n'importe quel
|
||||
hébergeur statique ; rien à compiler ;
|
||||
- optionnel : exclure `tests/`, `scripts/`, `docs/` du document root
|
||||
(inoffensifs mais inutiles en prod).
|
||||
L'app étant un site **100 % statique**, la conteneurisation est triviale :
|
||||
l'image ajoute UNIQUEMENT un serveur web. Points structurants :
|
||||
|
||||
| Aspect | Décision | Pourquoi |
|
||||
|---|---|---|
|
||||
| Image de base | `nginxinc/nginx-unprivileged:alpine` | run en uid 101 (pas de root), port 8080, writes limités à /tmp — meilleure pratique conteneur, ~50 Mo |
|
||||
| Données | **AUCUNE dans l'image** (`.dockerignore` exclut aussi `local-test-data` en filet de sécurité) | le conteneur distribue des fichiers ; la vie privée est identique (localStorage clients) — cf §3/§5 |
|
||||
| Contenu servi | index.html + css/js/assets + **docs/** (⚠️ le dialog « Nouveautés » fetch `docs/CHANGELOG.md` au démarrage) | pas de `tests/`, `scripts/`, npm dans l'image (audit facile : `find /usr/share/nginx/html`) |
|
||||
| Cache | `no-cache, must-revalidate` sur TOUT | l'app = un jeu de fichiers qui doivent rester COHÉRENTS entre eux ; un JS périmé au moment d'une mise à jour = état incohérent. Total ≈ 600 Ko + gzip sur l'asset PK (550 Ko → ~150 Ko) : coût nul |
|
||||
| Ports | 8080 (non privilégié) | l'image unprivileged ne peut pas binde <1024 |
|
||||
| Durcissement | `read_only: true` + tmpfs `/var/cache/nginx` + `/run`, `no-new-privileges` | site statique : AUCUNE écriture nécessaire ; si un hôte compose refuse les tmpfs, retirer les deux lignes |
|
||||
| Healthcheck | busybox `wget` sur `/` (30 s/3 s) | intégré au Dockerfile — orchestrateurs (compose/NAS) savent si le site répond |
|
||||
|
||||
**Fichiers** : `Dockerfile`, `nginx.conf` (server block remplacé),
|
||||
`.dockerignore` (exclusions documentées — `docs/` reste inclus),
|
||||
`docker-compose.yml` (durcissement inclus).
|
||||
|
||||
```bash
|
||||
# ── Build + run ──
|
||||
docker compose up -d # build + run → http://<hôte>:8080/
|
||||
docker compose up -d --build # rebuild après git pull (mise à jour = 30 s)
|
||||
docker tag hormonetrack-web:latest hormonetrack-web:vX.Y.Z # garder un historique d'images par release sync
|
||||
```
|
||||
|
||||
**Valider le conteneur avec le MÊME test E2E que le CI** (le script pilote
|
||||
n'importe quel serveur via `HRT_E2E_BASE` — à lancer sur l'hôte qui a
|
||||
Docker ET exécute `npm i && npx playwright install firefox` une fois, cf
|
||||
§8) :
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
HRT_E2E_BASE=http://127.0.0.1:8080 node scripts/e2e.mjs
|
||||
# → les mêmes ~30 assertions (rendu, canvas peint pixel par pixel,
|
||||
# navigation, écran changelog, 0 erreur console) contre le site SERVI
|
||||
# PAR LE CONTENEUR — un bug nginx/MIME casse l'app, le test le voit.
|
||||
```
|
||||
|
||||
**Fichiers servis vérifiés** : `/`, `/css/style.css`, `/assets/pk_profiles.json`,
|
||||
`/js/ui/app.js`, `/docs/CHANGELOG.md` — les mêmes que le smoke HTTP de
|
||||
`check.sh --with-serve`.
|
||||
|
||||
**Statut (8 sept. 2026)** : fichiers + docs prêts et relus — build NON
|
||||
exécuté localement (aucun daemon Docker sur la machine de dev, CLI seule) ;
|
||||
la configuration compose (`read_only`/tmpfs) est le pattern standard de
|
||||
nginx-unprivileged, à valider au premier `docker compose up` sur l'hôte
|
||||
cible. Si le durcissement pose problème sur ton NAS : retirer
|
||||
`read_only`/`tmpfs`/`security_opt` du compose (aucune perte de sécurité
|
||||
cruciale — le conteneur ne détient rien, cf plus haut).
|
||||
|
||||
## 11. Bugs potentiels évités pendant le portage
|
||||
|
||||
|
||||
58
nginx.conf
Normal file
58
nginx.conf
Normal file
@ -0,0 +1,58 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Config nginx du site HormoneTrack Web (conteneur — remplace le default.conf).
|
||||
#
|
||||
# La partie « serveur statique » est triviale ; les choix qui comptent :
|
||||
#
|
||||
# 1. **No-cache systématique** — raison : l'app est une SPA versionnée par un
|
||||
# jeu de fichiers (index.html + modules ES + asset PK) qui doivent rester
|
||||
# cohérents entre eux. Un cache client périmé AU MOMENT d'une mise à jour
|
||||
# d'image = code JS ancien + changelog nouveau = état incohérent. Le site
|
||||
# complet ≈ 600 Ko : le coût du re-téléchargement est négligeable sur un
|
||||
# LAN/VPN, et la fraîcheur après mise à jour est garantie.
|
||||
#
|
||||
# 2. **gzip** — l'asset pk_profiles.json (550 Ko de floats) se compresse
|
||||
# ~70 % : prend le seul fichier lourd en charge.
|
||||
#
|
||||
# 3. **En-têtes de sécurité minimaux** (site statique sans API, sans cookie,
|
||||
# sans iframe légitime).
|
||||
#
|
||||
# Ce fichier est copié dans /etc/nginx/conf.d/default.conf (server block) par
|
||||
# le Dockerfile — le http{} parent de l'image inclut déjà mime.types (.js
|
||||
# servi en application/javascript : requis pour les modules ES).
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
server {
|
||||
listen 8080;
|
||||
listen [::]:8080;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# gzip : l'asset PK (550 Ko) se compresse ~4× — le seul fichier lourd
|
||||
gzip on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 5;
|
||||
gzip_types application/json application/javascript text/css text/markdown text/plain;
|
||||
|
||||
# ── Headers appliqués à toutes les réponses ──────────────────────────────
|
||||
# No-cache « always » : une mise à jour d'image est prise en compte au
|
||||
# rechargement suivant, sans qu'aucun client conserve du code périmé.
|
||||
add_header Cache-Control "no-cache, must-revalidate" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
|
||||
# ── Routing ─────────────────────────────────────────────────────────────
|
||||
# SPA à fichier unique : pas de réécriture (le routing est par HASH
|
||||
# `#chart` etc., jamais par chemin) — un simple fallback 404 suffit.
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
# Défensif : ne jamais servir les fichiers cachés (.gitignore,
|
||||
# .dockerignore — ils sont normalement exclus par .dockerignore, la
|
||||
# règle reste en filet si quelqu'un build depuis une copie non propre).
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# check.sh — vérifications de l'app web (pré-commit / pré-push).
|
||||
# check.sh — vérifications de l'app web (pré-commit / pré-push / pré-release).
|
||||
#
|
||||
# 1. node --check sur chaque module ES (syntaxe) — attrape les typos avant
|
||||
# même de charger l'app dans un navigateur ;
|
||||
@ -12,12 +12,26 @@
|
||||
# install firefox, cf docs/DEVELOPPEMENT.md §Tests) ;
|
||||
# 6. smoke HTTP (--with-serve : serveur + curl des ressources clés).
|
||||
#
|
||||
# Usage : bash scripts/check.sh [--with-serve]
|
||||
# Mode RELEASE (--release) : ajoute les vérifications OBLIGATOIRES avant une
|
||||
# release (cf docs/DEVELOPPEMENT.md §10) —
|
||||
# 7. cohérence de la version : WEB_VERSION == section `## [X.Y.Z]` de
|
||||
# docs/CHANGELOG.md == tag annoté `vX.Y.Z` existant (le bump de version
|
||||
# jamais commité côté Android, bug #46, ne doit pas se reproduire ici) ;
|
||||
# 8. arbre git propre (tout commité avant publication) ;
|
||||
# 9. test conteneur RÉEL (scripts/container-test.sh — build/run durci/
|
||||
# headers/gzip/MIME/E2E contre le conteneur) si un daemon Docker est
|
||||
# joignable, ignoré sinon (à lancer sur l'hôte qui a Docker).
|
||||
#
|
||||
# Usage : bash scripts/check.sh [--with-serve] [--release]
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.." # racine du dépôt
|
||||
|
||||
fail=0
|
||||
RELEASE=false
|
||||
for arg in "$@"; do
|
||||
[[ "$arg" == "--release" ]] && RELEASE=1
|
||||
done
|
||||
|
||||
echo "── 1. Syntaxe des modules (node --check) ──"
|
||||
while IFS= read -r f; do
|
||||
@ -81,6 +95,47 @@ if [[ "${1:-}" == "--with-serve" ]]; then
|
||||
wait $SRV 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [[ "$RELEASE" == "1" ]]; then
|
||||
echo "── 7. Cohérence de la version (release) ──"
|
||||
ver=$(node --input-type=module -e "
|
||||
import { WEB_VERSION } from './js/ui/settings.js';
|
||||
process.stdout.write(WEB_VERSION);")
|
||||
verGrep=$(echo "$ver" | sed 's/\./\\./g') # 1.4.10 → 1\.4\.10 pour grep -E
|
||||
if grep -qE "^## \\[$verGrep\\]" docs/CHANGELOG.md; then
|
||||
echo " WEB_VERSION = $ver ; section [$ver] présente dans docs/CHANGELOG.md"
|
||||
else
|
||||
echo "✗ WEB_VERSION ($ver) n'a pas de section correspondante dans docs/CHANGELOG.md"
|
||||
fail=1
|
||||
fi
|
||||
# Le bump de version jamais commité (bug Android #46) ne doit pas se
|
||||
# reproduire : la release est faite SUR un tag qui existe.
|
||||
if git rev-parse -q --verify "refs/tags/v$ver" >/dev/null 2>&1; then
|
||||
echo " tag v$ver présent"
|
||||
else
|
||||
echo "✗ tag v$ver ABSENT — créer et pousser le tag avant la release (leçon Android #46)"
|
||||
fail=1
|
||||
fi
|
||||
|
||||
echo "── 8. Arbre git propre avant publication ──"
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
echo "✗ working tree sale — tout committer avant la release"
|
||||
git status --short | head -5
|
||||
fail=1
|
||||
else
|
||||
echo " working tree propre"
|
||||
fi
|
||||
|
||||
echo "── 9. Test conteneur RÉEL (si daemon Docker joignable) ──"
|
||||
if docker info --format x >/dev/null 2>&1; then
|
||||
if ! bash scripts/container-test.sh 8975; then
|
||||
fail=1
|
||||
fi
|
||||
else
|
||||
echo " (ignoré : aucun daemon Docker ici — lancer sur l'hôte cible :"
|
||||
echo " bash scripts/container-test.sh ; cf docs/DEVELOPPEMENT.md §10)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $fail -eq 0 ]]; then
|
||||
echo "✓ CHECK OK"
|
||||
else
|
||||
|
||||
126
scripts/container-test.sh
Executable file
126
scripts/container-test.sh
Executable file
@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env bash
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# container-test.sh — validation RÉELLE de l'image Docker HormoneTrack Web.
|
||||
#
|
||||
# C'est le test qui doit passer AVANT toute release web déployable en
|
||||
# conteneur (cf docs/DEVELOPPEMENT.md §10) : il attrape ce qu'aucun test
|
||||
# statique ne voit — MIME des modules ES (requis par les navigateurs),
|
||||
# headers réellement émis, gzip appliqué, fichiers exclus non servis,
|
||||
# healthcheck, et le comportement de l'app COMPLÈTE servie par le conteneur
|
||||
# (via l'E2E playwright branclé sur le conteneur).
|
||||
#
|
||||
# Prérequis : docker (daemon) ; node + playwright pour l'E2E final (skippé
|
||||
# sinon — cf check.sh pour l'installation).
|
||||
#
|
||||
# Usage : bash scripts/container-test.sh [port] (défaut 8975)
|
||||
#
|
||||
# ⚠️ Un SEUL invocation fait tout (leçon Android #43 : les invocations
|
||||
# rapprochées se remplacent mutuellement) et l'échec est BRUYANT (exit 1
|
||||
# à la première vérification fausse — jamais de « probablement bon »).
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.." # racine du dépôt
|
||||
|
||||
PORT="${1:-8975}"
|
||||
HOST_PORT="127.0.0.1:${PORT}"
|
||||
IMAGE="hormonetrack-web:smoke"
|
||||
CONTAINER="hrt-web-smoke"
|
||||
ROOM=0 # compteur d'échecs
|
||||
|
||||
fail() { echo " ✗ $1"; ROOM=$((ROOM + 1)); }
|
||||
ok() { echo " ✓ $1"; }
|
||||
|
||||
cleanup() {
|
||||
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "── 1. Build de l'image ──"
|
||||
docker build -t "$IMAGE" . 2>&1 | tail -2
|
||||
|
||||
echo "── 2. Run (durcissement compose : read_only + no-new-privileges) ──"
|
||||
# On teste l'image dans la configuration DURCIE de docker-compose.yml —
|
||||
# si ces options ne passent pas ici, elles n'auraient pas mieux passé en
|
||||
# prod. ⚠️ tmpfs /tmp requis : l'image nginx-unprivileged écrit ses
|
||||
# temporaires (proxy_temp…) dans /tmp — en read-only pur, nginx émerge
|
||||
# « mkdir /tmp/proxy_temp failed » (constaté au premier container-test).
|
||||
docker run -d --rm --name "$CONTAINER" \
|
||||
-p "${HOST_PORT}:8080" \
|
||||
--read-only \
|
||||
--tmpfs /var/cache/nginx --tmpfs /run --tmpfs /tmp \
|
||||
--security-opt no-new-privileges \
|
||||
"$IMAGE" >/dev/null
|
||||
|
||||
echo "── 3. Healthcheck (statut docker healthy) ──"
|
||||
state=""
|
||||
for _ in $(seq 1 30); do
|
||||
state=$(docker inspect --format '{{.State.Health.Status}}' "$CONTAINER" 2>/dev/null || echo "")
|
||||
if [[ "$state" == "healthy" ]]; then ok "healthcheck : healthy"; break; fi
|
||||
sleep 1
|
||||
done
|
||||
[[ "$state" == "healthy" ]] || fail "healthcheck jamais healthy (état: '${state:-inconnu}')"
|
||||
|
||||
echo "── 4. Endpoints clés (HTTP 200) ──"
|
||||
# Les MEMES chemins que le smoke HTTP de check.sh — le conteneur doit les
|
||||
# servir TOUS (l'oubli de docs/ casserait le dialog « Nouveautés »).
|
||||
for path in / /css/style.css /assets/pk_profiles.json /js/ui/app.js /docs/CHANGELOG.md; do
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' "http://${HOST_PORT}${path}")
|
||||
if [[ "$code" == "200" ]]; then ok "200 ${path}"; else fail "${path} → HTTP ${code}"; fi
|
||||
done
|
||||
|
||||
echo "── 5. Headers (no-cache + sécurité) ──"
|
||||
hdr=$(curl -s -D - -o /dev/null "http://${HOST_PORT}/")
|
||||
for want in "no-cache" "nosniff" "DENY" "no-referrer"; do
|
||||
if echo "$hdr" | grep -qi "$want"; then ok "header présent : $want"; else fail "header manquant : $want"; fi
|
||||
done
|
||||
|
||||
echo "── 5. gzip effectif sur l'asset PK (550 Ko → ~150 Ko) ──"
|
||||
enc=$(curl -s -H 'Accept-Encoding: gzip' -o /dev/null -w '%{header_json}' \
|
||||
"http://${HOST_PORT}/assets/pk_profiles.json")
|
||||
# ⚠️ curl %header_json produit des clés MINUSCULES (convention JSON) —
|
||||
# chercher « content-encoding » pas « Content-Encoding ».
|
||||
if echo "$enc" | grep -qi 'content-encoding.*gzip'; then ok "gzip appliqué"; else fail "pas de gzip sur l'asset — ${enc}"; fi
|
||||
|
||||
echo "── 6. MIME des modules ES (requête STRICTE des navigateurs) ──"
|
||||
# Le piège réel du portage : un .js servi en text/html → « disallowed MIME
|
||||
# type » et l'app ne charge PAS (cf §11 #6). On vérifie le CType réel.
|
||||
mime=$(curl -s -o /dev/null -w '%{content_type}' "http://${HOST_PORT}/js/ui/app.js")
|
||||
if echo "$mime" | grep -qi 'javascript'; then ok "module ES : ${mime}"; else fail "CType module ES = ${mime} (javascript requis)"; fi
|
||||
|
||||
echo "── 7. Fichiers cachés NON servis (défensif) ──"
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' "http://${HOST_PORT}/.gitignore")
|
||||
if [[ "$code" == "403" || "$code" == "404" ]]; then ok "fichier caché non servi (${code})"; else fail "fichier caché servi → HTTP ${code}"; fi
|
||||
|
||||
echo "── 8. Le conteneur ne contient AUCUNE donnée de dev ──"
|
||||
# Audit de contenu : ce qui est dans l'image (garde double de .dockerignore)
|
||||
for excl in package-lock.json node_modules; do
|
||||
if docker exec "$CONTAINER" find /usr/share/nginx/html -name "$excl" 2>/dev/null | grep -q .; then
|
||||
fail "$excl trouvé dans l'image"
|
||||
else
|
||||
ok "image propre : pas de ${excl}"
|
||||
fi
|
||||
done
|
||||
# docs/ DOIT être présent (dialog changelog)
|
||||
if docker exec "$CONTAINER" test -f /usr/share/nginx/html/docs/CHANGELOG.md; then
|
||||
ok "docs/CHANGELOG.md embarqué (dialog « Nouveautés »)"
|
||||
else
|
||||
fail "docs/CHANGELOG.md ABSENT de l'image — le dialog « Nouveautés » planerait"
|
||||
fi
|
||||
|
||||
echo "── 9. E2E navigateur complet contre le conteneur (si playwright dispo) ──"
|
||||
if [[ -d node_modules/playwright-core && -d "$HOME/Library/Caches/ms-playwright" ]]; then
|
||||
if HRT_E2E_BASE="http://${HOST_PORT}" node scripts/e2e.mjs; then
|
||||
ok "E2E complet contre le conteneur"
|
||||
else
|
||||
fail "E2E contre le conteneur"
|
||||
fi
|
||||
else
|
||||
echo " (ignoré : playwright non installé — npm i && npx playwright install firefox)"
|
||||
fi
|
||||
|
||||
if [[ $ROOM -eq 0 ]]; then
|
||||
echo "✓ CONTAINER TEST OK"
|
||||
else
|
||||
echo "✗ CONTAINER TEST : ${ROOM} échec(s)"
|
||||
exit 1
|
||||
fi
|
||||
@ -40,8 +40,23 @@ function ok(condition, label, detail = '') {
|
||||
}
|
||||
}
|
||||
|
||||
/** Démarre le serveur statique de dev et attend qu'il réponde. */
|
||||
/**
|
||||
* Démarre le serveur statique de dev et attend qu'il réponde.
|
||||
*
|
||||
* ⚠️ MODE CONTENEUR : si la variable d'environnement HRT_E2E_BASE est définie
|
||||
* (ex. HRT_E2E_BASE=http://127.0.0.1:8080), le serveur local n'est PAS
|
||||
* démarré — l'E2E pilote l'app SÉRVIE PAR CE BASE. C'est ce qui permet de
|
||||
* valider l'image Docker avec ce même test (cf docs/DEVELOPPEMENT.md §10
|
||||
* « Valider le conteneur ») : build + run sur l'hôte Docker, puis
|
||||
* `HRT_E2E_BASE=http://… node scripts/e2e.mjs`.
|
||||
*
|
||||
* @returns {process|null} le process serveur (à tuer à la fin), null en mode externe
|
||||
*/
|
||||
async function startServer() {
|
||||
if (process.env.HRT_E2E_BASE) {
|
||||
console.log(`serveur EXTERNE (HRT_E2E_BASE) : ${process.env.HRT_E2E_BASE}`);
|
||||
return null; // rien à démarrer, rien à tuer
|
||||
}
|
||||
const proc = spawn('python3', ['scripts/serve.py', '--port', String(PORT)], {
|
||||
cwd: WEB_ROOT,
|
||||
stdio: 'ignore',
|
||||
@ -60,7 +75,10 @@ async function startServer() {
|
||||
async function main() {
|
||||
mkdirSync(SHOTS, { recursive: true });
|
||||
const server = await startServer();
|
||||
console.log(`serveur : ${BASE}`);
|
||||
// BASE peut avoir été surchargée par HRT_E2E_BASE (mode conteneur) — les
|
||||
// navigations ci-dessous ciblent quel que soit le serveur qui répond.
|
||||
const base = process.env.HRT_E2E_BASE || BASE;
|
||||
console.log(`cible E2E : ${base}`);
|
||||
|
||||
const browser = await firefox.launch({
|
||||
headless: true,
|
||||
@ -90,7 +108,7 @@ async function main() {
|
||||
try {
|
||||
// ── 1. Accueil avec données de démo ─────────────────────────────────────
|
||||
console.log('── Accueil (?demo=1) ──');
|
||||
await page.goto(`${BASE}/?demo=1`, { waitUntil: 'load' });
|
||||
await page.goto(`${base}/?demo=1`, { waitUntil: 'load' });
|
||||
await page.waitForSelector('body[data-ready="1"]', { timeout: 15000 });
|
||||
await page.waitForSelector('.card', { timeout: 15000 });
|
||||
const bodyText = await page.textContent('main.content');
|
||||
@ -126,7 +144,7 @@ async function main() {
|
||||
|
||||
// ── 2. Graphiques : le Canvas peint de vraies courbes ───────────────────
|
||||
console.log('── Graphiques (#chart) ──');
|
||||
await page.goto(`${BASE}/#chart`, { waitUntil: 'load' });
|
||||
await page.goto(`${base}/#chart`, { waitUntil: 'load' });
|
||||
await page.waitForSelector('canvas.chart', { timeout: 15000 });
|
||||
await page.waitForTimeout(600); // requestAnimationFrame du dessin
|
||||
const canvasInfo = await page.evaluate(() => {
|
||||
@ -151,7 +169,7 @@ async function main() {
|
||||
|
||||
// ── 3. Navigation : les 5 onglets + settings ────────────────────────────
|
||||
console.log('── Navigation ──');
|
||||
await page.goto(`${BASE}/#doses`, { waitUntil: 'load' });
|
||||
await page.goto(`${base}/#doses`, { waitUntil: 'load' });
|
||||
await page.waitForSelector('body[data-ready="1"]');
|
||||
await page.waitForTimeout(300);
|
||||
let text = await page.textContent('main.content');
|
||||
@ -159,18 +177,18 @@ async function main() {
|
||||
ok(text.includes('EEn — démo'), 'Doses : la dose de démo est listée');
|
||||
ok(text.includes('7.0 j depuis la précédente'), 'Doses : intervalle entre doses');
|
||||
|
||||
await page.goto(`${BASE}/#labs`, { waitUntil: 'load' });
|
||||
await page.goto(`${base}/#labs`, { waitUntil: 'load' });
|
||||
await page.waitForTimeout(300);
|
||||
text = await page.textContent('main.content');
|
||||
ok(text.includes('E2') && text.includes('ng/dL'), 'Labs : prise groupée E2 + T (ng/dL converti au rendu seulement)');
|
||||
|
||||
await page.goto(`${BASE}/#treatments`, { waitUntil: 'load' });
|
||||
await page.goto(`${base}/#treatments`, { waitUntil: 'load' });
|
||||
await page.waitForTimeout(300);
|
||||
text = await page.textContent('main.content');
|
||||
ok(text.includes('EEn — démo'), 'Traitements : carte du traitement démo');
|
||||
ok(text.includes('Transfem Science'), 'Traitements : chip modèle (modelLabelKey)');
|
||||
|
||||
await page.goto(`${BASE}/#settings`, { waitUntil: 'load' });
|
||||
await page.goto(`${base}/#settings`, { waitUntil: 'load' });
|
||||
await page.waitForTimeout(300);
|
||||
text = await page.textContent('main.content');
|
||||
ok(text.includes('Langue'), 'Paramètres : carte langue');
|
||||
@ -185,7 +203,8 @@ async function main() {
|
||||
consoleErrors.slice(0, 3).join(' | '));
|
||||
} finally {
|
||||
await browser.close();
|
||||
server.kill();
|
||||
// Mode conteneur : pas de serveur local démarré → rien à tuer (cf startServer)
|
||||
server?.kill();
|
||||
}
|
||||
|
||||
if (failures === 0) {
|
||||
|
||||
205
scripts/publish-release.py
Normal file
205
scripts/publish-release.py
Normal file
@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Publie la release Gitea d'une version WEB (attache l'archive zip du site,
|
||||
VÉRIFIÉE PAR TÉLÉCHARGEMENT) — en UNE SEULE INVOCATION.
|
||||
|
||||
Portage du script Android `publish-release.py` (dépôt HormoneTrack),
|
||||
adapté au web :
|
||||
- UN SEUL artifact : `HormoneTrack-web-vX.Y.Z.zip` = déploiement
|
||||
statique sans git (index.html + css/ + js/ + assets/ + docs/ +
|
||||
LICENSE/README — le contenu runtime, cf .dockerignore) ;
|
||||
- l'archive est construite PAR `git archive` AU NIVEAU DU TAG : le zip
|
||||
correspond EXACTEMENT à ce qui est taggué (aucune divergence possible
|
||||
working tree ↔ release) ;
|
||||
- le corps de release = section `## [X.Y.Z]` de docs/CHANGELOG.md
|
||||
(identique à l'Android — c'est le même parseur, piège #31 : tag « vX »
|
||||
mais CHANGELOG sans v) ;
|
||||
- mêmes gardes que l'Android (leçons #37/#43) : purge des assets avant
|
||||
upload, vérification nom+taille par TÉLÉCHARGEMENT immédiat,
|
||||
re-vérification finale, échec BRUYANT.
|
||||
|
||||
⚠️ Note de parité : les APK Android mentionnent le tag du dépôt Android ; ce
|
||||
script ne doit être lancé QUE sur les tags du dépôt web (versions alignées).
|
||||
|
||||
Usage :
|
||||
python3 scripts/publish-release.py [instance] vX.Y.Z
|
||||
|
||||
Auth : trousseau macOS (security find-internet-password -s <hôte> -w),
|
||||
scope write:repository (suffit pour releases + assets).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
# Instances connues — miroir du script Android (mêmes noms courts)
|
||||
INSTANCES = {
|
||||
"cloudyfy": {"url": "https://gitea.cloudyfy.fr", "owner": "Siphonight",
|
||||
"host": "gitea.cloudyfy.fr"},
|
||||
"farewell": {"url": "https://gitea.farewell.dev", "owner": "Siphonight",
|
||||
"host": "gitea.farewell.dev"},
|
||||
}
|
||||
|
||||
REPO_NAME = "HormoneTrack-web"
|
||||
CHANGELOG = "docs/CHANGELOG.md"
|
||||
|
||||
|
||||
def gitea_token(host: str) -> str:
|
||||
out = subprocess.run(
|
||||
["security", "find-internet-password", "-s", host, "-w"],
|
||||
capture_output=True, text=True
|
||||
).stdout.strip()
|
||||
if not out:
|
||||
sys.exit(f"Token Gitea introuvable dans le trousseau pour {host}\n"
|
||||
f" security add-internet-password -s {host} -a Siphonight -w <TOKEN> -U")
|
||||
return out
|
||||
|
||||
|
||||
def request(url: str, method: str = "GET", data=None, token: str = "") -> dict:
|
||||
req = urllib.request.Request(url, method=method)
|
||||
req.add_header("Authorization", f"token {token}")
|
||||
if data is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
req.data = json.dumps(data).encode()
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
payload = resp.read().decode()
|
||||
return json.loads(payload) if payload.strip() else {}
|
||||
|
||||
|
||||
def _delivered_size(base: str, token: str, url: str) -> int:
|
||||
"""Taille RÉELLEMENT délivrée — le listing API peut mentir (leçon Android
|
||||
#37/#43 : on ne fait jamais confiance au listing, on télécharge)."""
|
||||
r = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||
with urllib.request.urlopen(r, timeout=300) as resp:
|
||||
return len(resp.read())
|
||||
|
||||
|
||||
def _upload_asset(base: str, token: str, release_id: int, name: str, path: str) -> dict:
|
||||
boundary = uuid.uuid4().hex
|
||||
head = (f"--{boundary}\r\n"
|
||||
f"Content-Disposition: form-data; name=\"attachment\"; filename=\"{name}\"\r\n"
|
||||
f"Content-Type: application/zip\r\n\r\n")
|
||||
tail = f"\r\n--{boundary}--\r\n"
|
||||
with open(path, "rb") as f:
|
||||
req = urllib.request.Request(
|
||||
f"{base}/releases/{release_id}/assets?name={name}", method="POST",
|
||||
headers={"Authorization": f"token {token}",
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}"})
|
||||
req.data = head.encode() + f.read() + tail.encode()
|
||||
with urllib.request.urlopen(req, timeout=300) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
|
||||
def changelog_section(version: str) -> str:
|
||||
"""Corps de release = section `## [X.Y.Z]` de docs/CHANGELOG.md.
|
||||
⚠️ Même piège Android #31 : le tag porte le « v », la CHANGELOG non."""
|
||||
num = version.removeprefix("v")
|
||||
text = open(CHANGELOG, encoding="utf-8").read()
|
||||
m = re.search(rf"## \[{re.escape(num)}\][^\n]*\n(.*?)(?=\n## \[|\Z)", text, re.S)
|
||||
if not m:
|
||||
sys.exit(f"Section [{num}] introuvable dans {CHANGELOG}")
|
||||
return m.group(1).strip()
|
||||
|
||||
|
||||
def verify_asset(base: str, token: str, release_id: int, asset_id: int,
|
||||
name: str, expected: int) -> bool:
|
||||
"""Vérifie par TÉLÉCHARGEMENT que l'asset a le bon nom et la bonne taille
|
||||
(le listing API a déjà menti côté Android — on ne le croit pas)."""
|
||||
release = request(f"{base}/releases/{release_id}", token=token)
|
||||
current = next((a for a in release["assets"] if a["id"] == asset_id), None) \
|
||||
if asset_id else next((a for a in release["assets"] if a["name"] == name), None)
|
||||
if current is None or current["name"] != name:
|
||||
return False
|
||||
if abs(current["size"] - expected) > 1000:
|
||||
return False
|
||||
delivered = _delivered_size(base, token, current["browser_download_url"])
|
||||
return delivered == expected
|
||||
|
||||
|
||||
def build_zip(tag: str) -> str:
|
||||
"""Construit l'archive de déploiement PAR GIT ARCHIVE au niveau du TAG —
|
||||
le zip publié = EXACTEMENT ce que le tag contient (jamais le working
|
||||
tree, qui peut être en avance). Exclusions : outils dev (tests/scripts)
|
||||
+ fichiers du conteneur — le même contenu que l'image Docker (cf
|
||||
.dockerignore) ; pour Docker, l'utilisatrice build depuis le dépôt.
|
||||
"""
|
||||
out = f"/tmp/HormoneTrack-web-{tag}.zip"
|
||||
paths = ["index.html", "css", "js", "assets", "docs", "LICENSE", "README.md"]
|
||||
subprocess.run(
|
||||
["git", "archive", "--format=zip", f"-o{out}", tag] + paths, check=True)
|
||||
print(f" zip construit depuis le tag : {out} ({os.path.getsize(out) // 1024} Ko)")
|
||||
return out
|
||||
|
||||
|
||||
def publish_zip(base: str, token: str, release_id: int, tag: str, zip_path: str) -> None:
|
||||
"""Purge + upload du zip + vérification IMMÉDIATE par téléchargement,
|
||||
puis re-vérification FINALE — le pattern Android qui marche
|
||||
(les uploads rapprochés se remplacent mutuellement, lesson #43 :
|
||||
tout est fait dans UNE invocation)."""
|
||||
name = f"HormoneTrack-web-{tag}.zip"
|
||||
|
||||
# 1) Purge de tout asset existant (idempotent : relancer le script est sûr)
|
||||
release = request(f"{base}/releases/{release_id}", token=token)
|
||||
for a in release["assets"]:
|
||||
urllib.request.urlopen(urllib.request.Request(
|
||||
f"{base}/releases/{release_id}/assets/{a['id']}", method="DELETE",
|
||||
headers={"Authorization": f"token {token}"}), timeout=60)
|
||||
print(f" purge : {a['name']}")
|
||||
|
||||
# 2) Upload + vérification immédiate par téléchargement
|
||||
name = f"HormoneTrack-web-{tag}.zip"
|
||||
expected = os.path.getsize(zip_path)
|
||||
uploaded = _upload_asset(base, token, release_id, name, zip_path)
|
||||
if not verify_asset(base, token, release_id, uploaded["id"], name, expected):
|
||||
sys.exit(f"✗ {name} : échec de vérification après upload")
|
||||
print(f" ✓ {name} ({expected // 1024} Ko)")
|
||||
|
||||
# 3) Re-vérification FINALE (la race se voyait seulement ici côté Android)
|
||||
if not verify_asset(base, token, release_id, 0, name, expected):
|
||||
sys.exit(f"✗ {name} : re-vérification finale échouée")
|
||||
print(f" ✓ zip vérifié par téléchargement")
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
instance = "cloudyfy"
|
||||
if args and not args[0].startswith("v"):
|
||||
instance = args.pop(0)
|
||||
if instance not in INSTANCES:
|
||||
sys.exit(f"Instance inconnue : {instance}")
|
||||
if not args:
|
||||
sys.exit(f"Usage: publish-release.py [cloudyfy|farewell] vX.Y.Z")
|
||||
tag = args[0]
|
||||
|
||||
conf = INSTANCES[instance]
|
||||
base = f"{conf['url']}/api/v1/repos/{conf['owner']}/{REPO_NAME}"
|
||||
token = gitea_token(conf["host"])
|
||||
body = changelog_section(tag)
|
||||
zip_path = build_zip(tag)
|
||||
|
||||
# Release : crée ou récupère + PATCH corps (idempotent)
|
||||
try:
|
||||
release = request(f"{base}/releases/tags/{tag}", token=token)
|
||||
request(f"{base}/releases/{release['id']}", "PATCH", token=token,
|
||||
data={"tag_name": tag, "name": tag, "body": body,
|
||||
"draft": False, "prerelease": False})
|
||||
release_id = release["id"]
|
||||
print(f"[{instance}] release {tag} existante (id {release_id}) — corps mis à jour")
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code != 404:
|
||||
raise
|
||||
release = request(f"{base}/releases", "POST", token=token, data={
|
||||
"tag_name": tag, "name": tag, "body": body,
|
||||
"draft": False, "prerelease": False})
|
||||
release_id = release["id"]
|
||||
print(f"[{instance}] release {tag} créée (id {release_id})")
|
||||
|
||||
publish_zip(base, token, release_id, tag, zip_path)
|
||||
print(f"[{instance}] Release {tag} complète ✓")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
x
Reference in New Issue
Block a user